echoclaw-relay-agent 0.9.3 → 0.9.4

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.
@@ -30,6 +30,7 @@ export declare class RelayAgent extends EventEmitter {
30
30
  private _stopped;
31
31
  private _starting;
32
32
  private _paired;
33
+ private _rekeyRetryCount;
33
34
  private _activePairing;
34
35
  private readonly config;
35
36
  private readonly subscribers;
@@ -97,6 +97,12 @@ export class RelayAgent extends EventEmitter {
97
97
  writable: true,
98
98
  value: false
99
99
  });
100
+ Object.defineProperty(this, "_rekeyRetryCount", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: 0
105
+ });
100
106
  Object.defineProperty(this, "_activePairing", {
101
107
  enumerable: true,
102
108
  configurable: true,
@@ -398,6 +404,7 @@ export class RelayAgent extends EventEmitter {
398
404
  // If desktop is online, ECDH completes in <1s; 30s gives ample margin.
399
405
  pairing.pairAsAgent(undefined, 30000)
400
406
  .then(result => {
407
+ this._rekeyRetryCount = 0; // Reset on success
401
408
  this.onPaired(result);
402
409
  // V2: Reattach gateway callbacks after reconnect
403
410
  if (this.gatewayManager) {
@@ -411,11 +418,28 @@ export class RelayAgent extends EventEmitter {
411
418
  })
412
419
  .catch(err => {
413
420
  this._activePairing = null;
421
+ this._rekeyRetryCount++;
422
+ // Circuit breaker: after 5 consecutive failures, force full reconnect cycle
423
+ // (disconnect + restart resets the relay server connection from scratch)
424
+ if (this._rekeyRetryCount >= 5) {
425
+ this._rekeyRetryCount = 0; // Reset for next cycle
426
+ this.emit('error', new Error(`Re-key failed 5 times, forcing fresh reconnect`));
427
+ if (this.transport) {
428
+ this.transport.disconnect();
429
+ this.transport.restart();
430
+ }
431
+ return;
432
+ }
414
433
  // Re-key failed — keep old crypto intact, mark as connected
415
434
  // (data handler still works with old keys if desktop didn't re-key either)
416
435
  if (this._paired && this.sessionKey) {
417
436
  this.setStatus('connected');
418
437
  }
438
+ // If WS is still OPEN, force reconnect to trigger fresh on('open')
439
+ if (this.transport?.isConnected) {
440
+ this.transport.disconnect();
441
+ this.transport.restart();
442
+ }
419
443
  this.emit('error', err);
420
444
  });
421
445
  }
@@ -36,6 +36,7 @@ export declare class RelayClient extends EventEmitter {
36
36
  private _stopped;
37
37
  private _paired;
38
38
  private _needsRekey;
39
+ private _rekeyRetryCount;
39
40
  private _activePairing;
40
41
  private readonly config;
41
42
  private readonly sessionStore;
@@ -91,6 +91,12 @@ export class RelayClient extends EventEmitter {
91
91
  writable: true,
92
92
  value: false
93
93
  });
94
+ Object.defineProperty(this, "_rekeyRetryCount", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: 0
99
+ });
94
100
  Object.defineProperty(this, "_activePairing", {
95
101
  enumerable: true,
96
102
  configurable: true,
@@ -371,12 +377,30 @@ export class RelayClient extends EventEmitter {
371
377
  const pairing = new PairingProtocol(this.transport);
372
378
  this._activePairing = pairing;
373
379
  pairing.pairAsClient(this.pairingCode)
374
- .then(result => this.onPaired(result, 'resumed'))
380
+ .then(result => {
381
+ this._rekeyRetryCount = 0; // Reset on success
382
+ this.onPaired(result, 'resumed');
383
+ })
375
384
  .catch(err => {
376
385
  this._activePairing = null;
377
- // Re-key failed — set _needsRekey back to true so next reconnect retries.
378
- // Without this, the client gets stuck with no crypto and no recovery path.
386
+ this._rekeyRetryCount++;
387
+ // Circuit breaker: stop after 5 consecutive re-key failures
388
+ if (this._rekeyRetryCount >= 5) {
389
+ this._needsRekey = false;
390
+ this.emit('error', new Error(`Re-key failed after ${this._rekeyRetryCount} retries, giving up`));
391
+ // Force transport to close and reconnect from scratch
392
+ this.transport?.disconnect();
393
+ return;
394
+ }
395
+ // Re-key failed — set _needsRekey back to true for retry.
379
396
  this._needsRekey = true;
397
+ // If WS is still OPEN (pairing failed at protocol level, not transport level),
398
+ // _needsRekey won't be consumed until next on('open').
399
+ // Force a reconnect to trigger a fresh on('open').
400
+ if (this.transport?.isConnected) {
401
+ this.transport.disconnect();
402
+ this.transport.restart();
403
+ }
380
404
  this.emit('error', err);
381
405
  });
382
406
  }
@@ -149,7 +149,10 @@ export declare class ChatHandler {
149
149
  private _extractText;
150
150
  /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
151
151
  private _send;
152
- /** Flush buffered messages to desktop after relay reconnects. */
152
+ /** Sequential send queue to prevent message reordering between flush and real-time messages. */
153
+ private _sendQueue;
154
+ private _enqueueSend;
155
+ /** Flush buffered messages to desktop after relay reconnects (sequential, preserves order). */
153
156
  private _flushMessageBuffer;
154
157
  /**
155
158
  * Send a message via a captured sendBack reference.
@@ -112,6 +112,13 @@ export class ChatHandler {
112
112
  writable: true,
113
113
  value: []
114
114
  });
115
+ /** Sequential send queue to prevent message reordering between flush and real-time messages. */
116
+ Object.defineProperty(this, "_sendQueue", {
117
+ enumerable: true,
118
+ configurable: true,
119
+ writable: true,
120
+ value: Promise.resolve()
121
+ });
115
122
  this.sessionKey = config?.sessionKey ?? 'main';
116
123
  this.requestTimeoutMs = config?.requestTimeoutMs ?? 30000;
117
124
  this.wsClient = new OpenClawWsClient({
@@ -569,7 +576,8 @@ export class ChatHandler {
569
576
  /** Send a message back to desktop via the persistent callback. Buffers if disconnected. */
570
577
  async _send(msg) {
571
578
  if (this._sendBack) {
572
- await this._sendBack(msg);
579
+ // Use send queue to guarantee ordering (flush + real-time share same queue)
580
+ await this._enqueueSend(msg);
573
581
  }
574
582
  else {
575
583
  // Buffer important messages while relay is disconnected (OpenClaw may still be responding)
@@ -580,13 +588,22 @@ export class ChatHandler {
580
588
  }
581
589
  }
582
590
  }
583
- /** Flush buffered messages to desktop after relay reconnects. */
591
+ _enqueueSend(msg) {
592
+ const p = this._sendQueue.then(async () => {
593
+ if (this._sendBack) {
594
+ await this._sendBack(msg);
595
+ }
596
+ }).catch(() => { });
597
+ this._sendQueue = p;
598
+ return p;
599
+ }
600
+ /** Flush buffered messages to desktop after relay reconnects (sequential, preserves order). */
584
601
  _flushMessageBuffer() {
585
602
  if (!this._sendBack || this._messageBuffer.length === 0)
586
603
  return;
587
604
  const buf = this._messageBuffer.splice(0);
588
605
  for (const msg of buf) {
589
- this._sendBack(msg).catch(() => { });
606
+ this._enqueueSend(msg);
590
607
  }
591
608
  }
592
609
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",