echoclaw-relay-agent 0.8.0 → 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;
@@ -245,12 +260,21 @@ export class RelayAgent extends EventEmitter {
245
260
  await this.onPaired(result);
246
261
  }
247
262
  catch (err) {
248
- // Resume failed — clear stale session to prevent infinite retry loop
263
+ // Resume failed — only clear session for permanent errors
249
264
  this.transport?.disconnect();
250
265
  this.transport = null;
251
266
  this.frameCrypto = null;
252
267
  this.setStatus('disconnected');
253
- await this.sessionStore.clear();
268
+ const errMsg = err instanceof Error ? err.message : String(err);
269
+ const isPermanent = errMsg.includes('DEVICE_TOKEN_MISMATCH') ||
270
+ errMsg.includes('SESSION_NOT_FOUND') ||
271
+ errMsg.includes('SESSION_EXPIRED') ||
272
+ errMsg.includes('INVALID_SESSION');
273
+ if (isPermanent) {
274
+ // Permanent rejection — session is truly dead, clear it
275
+ await this.sessionStore.clear();
276
+ }
277
+ // Transient errors (network, timeout) — keep session.json for retry
254
278
  throw err;
255
279
  }
256
280
  }
@@ -259,6 +283,8 @@ export class RelayAgent extends EventEmitter {
259
283
  this.sessionId = result.sessionId;
260
284
  this.pairingCode = result.pairingCode;
261
285
  this.frameCrypto = new FrameCrypto(result.sessionKey);
286
+ this._paired = true;
287
+ this._activePairing = null;
262
288
  // Persist session for future auto-resume
263
289
  await this.sessionStore.save({
264
290
  pairingCode: result.pairingCode,
@@ -346,18 +372,33 @@ export class RelayAgent extends EventEmitter {
346
372
  this.emit('error', err);
347
373
  });
348
374
  this.transport.on('open', () => {
349
- if (this.sessionKey) {
350
- this.setStatus('connected');
351
- this.emit('connected');
352
- // V2: Reattach gateway callbacks after reconnect
353
- if (this.gatewayManager) {
354
- this.gatewayManager.onReconnected(async (r) => {
355
- try {
356
- await this.send(r);
357
- }
358
- catch { /* send may fail */ }
359
- });
360
- }
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
+ });
361
402
  }
362
403
  });
363
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
  }
package/dist/cli.js CHANGED
@@ -234,6 +234,13 @@ async function runSetup(code, relay, bridgePort) {
234
234
  console.log();
235
235
  // Step 2: Stop the foreground agent (service will take over)
236
236
  await agent.stop();
237
+ // Verify session.json exists before installing service
238
+ const { SessionStore } = await import('./relay/SessionStore.js');
239
+ const store = new SessionStore();
240
+ const savedSession = await store.load();
241
+ if (!savedSession) {
242
+ throw new Error('Session file not found after pairing — cannot install service. Try setup again.');
243
+ }
237
244
  // Step 3: Install as system service (WITHOUT pairing code — it resumes from session)
238
245
  printStep('2/3', 'Installing system service...');
239
246
  const svc = await getServiceManager();
@@ -354,7 +361,32 @@ async function runDaemon(relay, code, gateway, bridgePort) {
354
361
  process.once('SIGINT', shutdown);
355
362
  process.once('SIGTERM', shutdown);
356
363
  console.log(` [start] relay=${relay} gateway=${!!gateway}`);
357
- await agent.start(code);
364
+ // Retry logic — transient failures (network, server restart) shouldn't kill the service
365
+ const MAX_RETRIES = 5;
366
+ const RETRY_DELAY = 3000;
367
+ let lastErr;
368
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
369
+ try {
370
+ await agent.start(code);
371
+ return; // success — stay alive
372
+ }
373
+ catch (err) {
374
+ lastErr = err;
375
+ const msg = err instanceof Error ? err.message : String(err);
376
+ console.error(` [start-failed] attempt=${attempt}/${MAX_RETRIES} error=${msg}`);
377
+ // Permanent errors — don't retry
378
+ if (msg.includes('No saved session found') ||
379
+ msg.includes('DEVICE_TOKEN_MISMATCH') ||
380
+ msg.includes('SESSION_NOT_FOUND')) {
381
+ break;
382
+ }
383
+ if (attempt < MAX_RETRIES) {
384
+ console.log(` [retry] waiting ${RETRY_DELAY / 1000}s...`);
385
+ await new Promise(r => setTimeout(r, RETRY_DELAY));
386
+ }
387
+ }
388
+ }
389
+ throw lastErr;
358
390
  }
359
391
  // ── Main ─────────────────────────────────────────────────────
360
392
  async function main() {
@@ -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.0",
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",