echoclaw-relay-agent 0.8.1 → 0.8.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.
@@ -29,6 +29,8 @@ export declare class RelayAgent extends EventEmitter {
29
29
  private _dataHandler;
30
30
  private _stopped;
31
31
  private _starting;
32
+ private _paired;
33
+ private _activePairing;
32
34
  private readonly config;
33
35
  private readonly subscribers;
34
36
  private gatewayManager;
@@ -91,6 +91,18 @@ export class RelayAgent extends EventEmitter {
91
91
  writable: true,
92
92
  value: false
93
93
  });
94
+ Object.defineProperty(this, "_paired", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: false
99
+ });
100
+ Object.defineProperty(this, "_activePairing", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: null
105
+ });
94
106
  Object.defineProperty(this, "config", {
95
107
  enumerable: true,
96
108
  configurable: true,
@@ -194,6 +206,9 @@ export class RelayAgent extends EventEmitter {
194
206
  /** Gracefully stop the agent. */
195
207
  async stop() {
196
208
  this._stopped = true;
209
+ this._paired = false;
210
+ this._activePairing?.abort();
211
+ this._activePairing = null;
197
212
  this.gatewayManager?.stop();
198
213
  this.transport?.disconnect();
199
214
  this.transport = null;
@@ -268,6 +283,8 @@ export class RelayAgent extends EventEmitter {
268
283
  this.sessionId = result.sessionId;
269
284
  this.pairingCode = result.pairingCode;
270
285
  this.frameCrypto = new FrameCrypto(result.sessionKey);
286
+ this._paired = true;
287
+ this._activePairing = null;
271
288
  // Persist session for future auto-resume
272
289
  await this.sessionStore.save({
273
290
  pairingCode: result.pairingCode,
@@ -355,18 +372,33 @@ export class RelayAgent extends EventEmitter {
355
372
  this.emit('error', err);
356
373
  });
357
374
  this.transport.on('open', () => {
358
- if (this.sessionKey) {
359
- this.setStatus('connected');
360
- this.emit('connected');
361
- // V2: Reattach gateway callbacks after reconnect
362
- if (this.gatewayManager) {
363
- this.gatewayManager.onReconnected(async (r) => {
364
- try {
365
- await this.send(r);
366
- }
367
- catch { /* send may fail */ }
368
- });
369
- }
375
+ if (this._paired && this.transport) {
376
+ // Transport reconnected after prior successful pairing —
377
+ // redo ECDH because desktop may have new ephemeral keys.
378
+ // Cancel any in-flight pairing and invalidate old crypto state.
379
+ this._activePairing?.abort();
380
+ this.sessionKey = null;
381
+ this.frameCrypto = null;
382
+ this.setStatus('connecting');
383
+ const pairing = new PairingProtocol(this.transport);
384
+ this._activePairing = pairing;
385
+ pairing.pairAsAgent()
386
+ .then(result => {
387
+ this.onPaired(result);
388
+ // V2: Reattach gateway callbacks after reconnect
389
+ if (this.gatewayManager) {
390
+ this.gatewayManager.onReconnected(async (r) => {
391
+ try {
392
+ await this.send(r);
393
+ }
394
+ catch { /* send may fail */ }
395
+ });
396
+ }
397
+ })
398
+ .catch(err => {
399
+ this._activePairing = null;
400
+ this.emit('error', err);
401
+ });
370
402
  }
371
403
  });
372
404
  }
@@ -34,6 +34,9 @@ export declare class RelayClient extends EventEmitter {
34
34
  private _status;
35
35
  private _connecting;
36
36
  private _stopped;
37
+ private _paired;
38
+ private _needsRekey;
39
+ private _activePairing;
37
40
  private readonly config;
38
41
  private readonly sessionStore;
39
42
  private readonly subscribers;
@@ -79,6 +79,24 @@ export class RelayClient extends EventEmitter {
79
79
  writable: true,
80
80
  value: false
81
81
  });
82
+ Object.defineProperty(this, "_paired", {
83
+ enumerable: true,
84
+ configurable: true,
85
+ writable: true,
86
+ value: false
87
+ });
88
+ Object.defineProperty(this, "_needsRekey", {
89
+ enumerable: true,
90
+ configurable: true,
91
+ writable: true,
92
+ value: false
93
+ });
94
+ Object.defineProperty(this, "_activePairing", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: null
99
+ });
82
100
  Object.defineProperty(this, "config", {
83
101
  enumerable: true,
84
102
  configurable: true,
@@ -178,6 +196,10 @@ export class RelayClient extends EventEmitter {
178
196
  /** Disconnect from the relay. */
179
197
  async disconnect() {
180
198
  this._stopped = true;
199
+ this._paired = false;
200
+ this._needsRekey = false;
201
+ this._activePairing?.abort();
202
+ this._activePairing = null;
181
203
  this.transport?.disconnect();
182
204
  this.transport = null;
183
205
  this.sessionKey = null;
@@ -250,6 +272,8 @@ export class RelayClient extends EventEmitter {
250
272
  this.sessionId = result.sessionId;
251
273
  this.pairingCode = result.pairingCode;
252
274
  this.frameCrypto = new FrameCrypto(result.sessionKey);
275
+ this._paired = true;
276
+ this._activePairing = null;
253
277
  // Update transport URL so auto-reconnect uses ?resume=sessionId
254
278
  // instead of the original /client/connect (which would create a new session)
255
279
  if (this.transport) {
@@ -319,8 +343,34 @@ export class RelayClient extends EventEmitter {
319
343
  this.transport.on('error', (err) => {
320
344
  this.emit('error', err);
321
345
  });
346
+ // Listen for server CLOSE messages (e.g. AGENT_RESTARTED) to know re-ECDH is needed.
347
+ // Without this, normal network drops would unnecessarily trigger re-ECDH.
348
+ this.transport.on('message', (msg) => {
349
+ if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
350
+ this._needsRekey = true;
351
+ }
352
+ });
322
353
  this.transport.on('open', () => {
323
- if (this.sessionKey) {
354
+ if (this._paired && this._needsRekey && this.pairingCode && this.transport) {
355
+ // Server requested re-ECDH (e.g. AGENT_RESTARTED) —
356
+ // cancel any in-flight pairing and start fresh handshake.
357
+ // Invalidate old crypto state to prevent sending with stale keys.
358
+ this._needsRekey = false;
359
+ this._activePairing?.abort();
360
+ this.sessionKey = null;
361
+ this.frameCrypto = null;
362
+ this.setStatus('connecting');
363
+ const pairing = new PairingProtocol(this.transport);
364
+ this._activePairing = pairing;
365
+ pairing.pairAsClient(this.pairingCode)
366
+ .then(result => this.onPaired(result, 'resumed'))
367
+ .catch(err => {
368
+ this._activePairing = null;
369
+ this.emit('error', err);
370
+ });
371
+ }
372
+ else if (this.sessionKey) {
373
+ // Normal reconnect (network drop) — old session key still valid
324
374
  this.setStatus('connected');
325
375
  this.emit('connected');
326
376
  }
@@ -24,7 +24,10 @@ export interface PairingResult {
24
24
  export declare class PairingProtocol extends EventEmitter {
25
25
  private readonly transport;
26
26
  private keyPair;
27
+ private _cleanup;
27
28
  constructor(transport: RelayTransport);
29
+ /** Abort an in-flight pairing attempt. Safe to call multiple times. */
30
+ abort(): void;
28
31
  /**
29
32
  * Agent-side pairing: register with relay, wait for client to pair.
30
33
  * Returns the E2E session key once pairing is complete.
@@ -31,6 +31,17 @@ export class PairingProtocol extends EventEmitter {
31
31
  writable: true,
32
32
  value: null
33
33
  });
34
+ Object.defineProperty(this, "_cleanup", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: null
39
+ });
40
+ }
41
+ /** Abort an in-flight pairing attempt. Safe to call multiple times. */
42
+ abort() {
43
+ this._cleanup?.();
44
+ this._cleanup = null;
34
45
  }
35
46
  /**
36
47
  * Agent-side pairing: register with relay, wait for client to pair.
@@ -52,7 +63,10 @@ export class PairingProtocol extends EventEmitter {
52
63
  resolved = true;
53
64
  clearTimeout(timeout);
54
65
  this.transport.removeListener('message', onMessage);
66
+ this._cleanup = null;
55
67
  };
68
+ // Expose cleanup for external abort()
69
+ this._cleanup = cleanup;
56
70
  const onMessage = async (msg) => {
57
71
  if (resolved)
58
72
  return;
@@ -124,7 +138,10 @@ export class PairingProtocol extends EventEmitter {
124
138
  clearTimeout(timeout);
125
139
  this.transport.removeListener('message', onMessage);
126
140
  this.transport.removeListener('open', onOpen);
141
+ this._cleanup = null;
127
142
  };
143
+ // Expose cleanup for external abort()
144
+ this._cleanup = cleanup;
128
145
  const sendPubkey = () => {
129
146
  if (sentHello)
130
147
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.8.1",
3
+ "version": "0.8.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",