echoclaw-relay-agent 0.19.2 → 0.19.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.
@@ -602,18 +602,19 @@ export class RelayAgent extends EventEmitter {
602
602
  this._identityExchanged = true;
603
603
  return;
604
604
  }
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
605
+ // SYMMETRIC EXCHANGE: Send our identity FIRST, then wait for peer's.
606
+ // Both sides send immediately upon pairing eliminates "who sends first" race.
611
607
  const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
612
608
  await this.send({
613
609
  type: 'identity_exchange',
614
610
  ed25519_pubkey: myPubKeyBase64,
615
611
  device_id: identity.deviceId,
616
612
  });
613
+ // Remove early handler before entering wait — prevents both handlers from
614
+ // processing the same message simultaneously.
615
+ this.removeListener('message', earlyHandler);
616
+ // Use early-captured message if available, otherwise wait with timeout
617
+ const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(15000);
617
618
  // Persist peer's public key
618
619
  await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
619
620
  sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
@@ -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;
package/dist/cli.js CHANGED
@@ -326,6 +326,25 @@ async function runSetup(code, relay, bridgePort) {
326
326
  const pairInfo = await pairPromise;
327
327
  printOk(`Paired with desktop client`);
328
328
  console.log(` ${DIM}Session: ${pairInfo.sessionId.slice(0, 8)}...${RESET}`);
329
+ // Wait for identity exchange to complete (or timeout gracefully)
330
+ // Identity exchange starts inside onPaired() — give it time before stopping.
331
+ await new Promise((resolve) => {
332
+ const onIdentity = () => { cleanup(); resolve(); };
333
+ const onError = (err) => {
334
+ if (err.constructor?.name === 'IdentityExchangeError') {
335
+ cleanup();
336
+ resolve();
337
+ }
338
+ };
339
+ const timer = setTimeout(() => { cleanup(); resolve(); }, 12000); // 12s > 10s identity timeout
340
+ const cleanup = () => {
341
+ clearTimeout(timer);
342
+ agent.removeListener('identity_exchanged', onIdentity);
343
+ agent.removeListener('error', onError);
344
+ };
345
+ agent.on('identity_exchanged', onIdentity);
346
+ agent.on('error', onError);
347
+ });
329
348
  console.log();
330
349
  // Step 2: Stop the foreground agent (service will take over)
331
350
  await agent.stop();
@@ -174,18 +174,18 @@ export class PairingProtocol extends EventEmitter {
174
174
  code = msg.payload;
175
175
  this.emit('pairing_code', code);
176
176
  }
177
- // In initiator mode, send pubkey after receiving server HELLO
178
- if (isInitiator) {
179
- sendPubkey();
180
- }
177
+ // Do NOT send pubkey here in initiator mode agent hasn't connected yet.
178
+ // Wait for agent's HELLO with pubkey first (see below).
181
179
  }
182
180
  // Track session_id from any message
183
181
  if (msg.session_id && !sessionId) {
184
182
  sessionId = msg.session_id;
185
183
  }
186
- // Agent sends their public key — complete ECDH
184
+ // Agent sends their public key — send ours back, then complete ECDH
187
185
  if (msg.type === 'HELLO' && msg.pubkey && msg.sender_role === 'agent') {
188
186
  try {
187
+ // Send our pubkey AFTER receiving agent's (so agent WS is guaranteed connected)
188
+ sendPubkey();
189
189
  const theirPub = fromBase64(msg.pubkey);
190
190
  const sessionKey = await completeHandshake(this.keyPair.privateKey, theirPub, code || undefined);
191
191
  cleanup();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.19.2",
3
+ "version": "0.19.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",