echoclaw-relay-agent 0.19.3 → 0.20.0
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.
- package/dist/RelayAgent.d.ts +1 -0
- package/dist/RelayAgent.js +39 -12
- package/dist/RelayClient.js +45 -17
- package/dist/chat/ChatHandler.js +2 -0
- package/dist/relay/PairingProtocol.js +21 -11
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/RelayAgent.d.ts
CHANGED
package/dist/RelayAgent.js
CHANGED
|
@@ -81,6 +81,12 @@ export class RelayAgent extends EventEmitter {
|
|
|
81
81
|
writable: true,
|
|
82
82
|
value: null
|
|
83
83
|
});
|
|
84
|
+
Object.defineProperty(this, "_dataHandlerInner", {
|
|
85
|
+
enumerable: true,
|
|
86
|
+
configurable: true,
|
|
87
|
+
writable: true,
|
|
88
|
+
value: null
|
|
89
|
+
});
|
|
84
90
|
Object.defineProperty(this, "_stopped", {
|
|
85
91
|
enumerable: true,
|
|
86
92
|
configurable: true,
|
|
@@ -286,9 +292,11 @@ export class RelayAgent extends EventEmitter {
|
|
|
286
292
|
this.sessionKey = null;
|
|
287
293
|
this.frameCrypto = null;
|
|
288
294
|
this._dataHandler = null;
|
|
295
|
+
this._dataHandlerInner = null;
|
|
289
296
|
this._rekeyListener = null;
|
|
290
297
|
this._proactiveKeyPair = null;
|
|
291
298
|
this._rekeyInFlight = false;
|
|
299
|
+
this._identityExchanged = false;
|
|
292
300
|
this.setStatus('disconnected');
|
|
293
301
|
}
|
|
294
302
|
// ── Private ────────────────────────────────────────────────
|
|
@@ -552,7 +560,16 @@ export class RelayAgent extends EventEmitter {
|
|
|
552
560
|
this.emit('error', ixErr);
|
|
553
561
|
}
|
|
554
562
|
}
|
|
555
|
-
// Persist session
|
|
563
|
+
// Persist session — merge with existing to preserve identity data on re-key.
|
|
564
|
+
// Fresh pairing writes clean; re-key only updates sessionKey/sessionId.
|
|
565
|
+
if (!freshPairing) {
|
|
566
|
+
const existing = await this.sessionStore.load();
|
|
567
|
+
if (existing) {
|
|
568
|
+
sessionData.identityDeviceId = sessionData.identityDeviceId || existing.identityDeviceId;
|
|
569
|
+
sessionData.peerPublicKeyRaw = sessionData.peerPublicKeyRaw || existing.peerPublicKeyRaw;
|
|
570
|
+
sessionData.bindingSecret = sessionData.bindingSecret || existing.bindingSecret;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
556
573
|
await this.sessionStore.save(sessionData);
|
|
557
574
|
// Re-install re-key listener so future desktop HELLOs are handled.
|
|
558
575
|
// Without this, after a re-key, the listener references the old transport
|
|
@@ -602,18 +619,19 @@ export class RelayAgent extends EventEmitter {
|
|
|
602
619
|
this._identityExchanged = true;
|
|
603
620
|
return;
|
|
604
621
|
}
|
|
605
|
-
//
|
|
606
|
-
//
|
|
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
|
|
622
|
+
// SYMMETRIC EXCHANGE: Send our identity FIRST, then wait for peer's.
|
|
623
|
+
// Both sides send immediately upon pairing — eliminates "who sends first" race.
|
|
611
624
|
const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
|
|
612
625
|
await this.send({
|
|
613
626
|
type: 'identity_exchange',
|
|
614
627
|
ed25519_pubkey: myPubKeyBase64,
|
|
615
628
|
device_id: identity.deviceId,
|
|
616
629
|
});
|
|
630
|
+
// Remove early handler before entering wait — prevents both handlers from
|
|
631
|
+
// processing the same message simultaneously.
|
|
632
|
+
this.removeListener('message', earlyHandler);
|
|
633
|
+
// Use early-captured message if available, otherwise wait with timeout
|
|
634
|
+
const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(15000);
|
|
617
635
|
// Persist peer's public key
|
|
618
636
|
await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
|
|
619
637
|
sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
|
|
@@ -650,11 +668,16 @@ export class RelayAgent extends EventEmitter {
|
|
|
650
668
|
setupDataHandler() {
|
|
651
669
|
if (!this.transport)
|
|
652
670
|
return;
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
671
|
+
// Single permanent listener pattern: install once, swap inner handler on re-key.
|
|
672
|
+
// Prevents listener accumulation from multiple onPaired() calls.
|
|
673
|
+
if (!this._dataHandler) {
|
|
674
|
+
this._dataHandler = (msg) => {
|
|
675
|
+
if (this._dataHandlerInner)
|
|
676
|
+
this._dataHandlerInner(msg);
|
|
677
|
+
};
|
|
678
|
+
this.transport.on('message', this._dataHandler);
|
|
656
679
|
}
|
|
657
|
-
this.
|
|
680
|
+
this._dataHandlerInner = async (msg) => {
|
|
658
681
|
if (msg.type !== 'DATA' || !msg.iv || !msg.payload || !this.frameCrypto)
|
|
659
682
|
return;
|
|
660
683
|
try {
|
|
@@ -693,7 +716,6 @@ export class RelayAgent extends EventEmitter {
|
|
|
693
716
|
this.emit('error', err);
|
|
694
717
|
}
|
|
695
718
|
};
|
|
696
|
-
this.transport.on('message', this._dataHandler);
|
|
697
719
|
}
|
|
698
720
|
setupTransportEvents() {
|
|
699
721
|
if (!this.transport)
|
|
@@ -702,6 +724,11 @@ export class RelayAgent extends EventEmitter {
|
|
|
702
724
|
if (!this._stopped) {
|
|
703
725
|
this.setStatus('reconnecting');
|
|
704
726
|
this.emit('disconnected', { code: 0, reason: 'transport closed' });
|
|
727
|
+
// Reset re-key state on transport close — new connection = fresh re-key.
|
|
728
|
+
// Prevents deadlock where old _rekeyInFlight blocks new connection's re-key.
|
|
729
|
+
this._rekeyInFlight = false;
|
|
730
|
+
this._activePairing?.abort();
|
|
731
|
+
this._activePairing = null;
|
|
705
732
|
// V2: Notify gateway of disconnect (starts grace period)
|
|
706
733
|
this.gatewayManager?.onDisconnected();
|
|
707
734
|
}
|
package/dist/RelayClient.js
CHANGED
|
@@ -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
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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(
|
|
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;
|
|
@@ -675,21 +685,39 @@ export class RelayClient extends EventEmitter {
|
|
|
675
685
|
this.transport.on('connect_timeout', (timeoutMs) => {
|
|
676
686
|
this.emit('error', Object.assign(new Error(`Connection timed out after ${timeoutMs / 1000}s`), { code: 'CONNECT_TIMEOUT' }));
|
|
677
687
|
});
|
|
678
|
-
// Listen for server CLOSE messages
|
|
688
|
+
// Listen for server CLOSE/STATUS messages — whitelist dispatch.
|
|
689
|
+
// Only specific payloads trigger re-key. Others dispatch to handlers.
|
|
690
|
+
const REKEY_TRIGGERS = new Set(['AGENT_RESTARTED', 'FORCE_REKEY']);
|
|
691
|
+
const PEER_STATUS = new Set(['AGENT_WARNING', 'AGENT_ONLINE', 'DESKTOP_DISCONNECTED']);
|
|
692
|
+
const SESSION_FATAL = new Set(['SESSION_NOT_FOUND', 'INVALID_SESSION']);
|
|
679
693
|
this.transport.on('message', (msg) => {
|
|
680
|
-
if (msg.type === 'CLOSE' && msg.sender_role === 'server') {
|
|
681
|
-
const payload = msg.payload;
|
|
682
|
-
if (payload
|
|
683
|
-
|
|
694
|
+
if ((msg.type === 'CLOSE' || msg.type === 'STATUS') && msg.sender_role === 'server') {
|
|
695
|
+
const payload = typeof msg.payload === 'string' ? msg.payload : '';
|
|
696
|
+
if (REKEY_TRIGGERS.has(payload)) {
|
|
697
|
+
this._needsRekey = true;
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (PEER_STATUS.has(payload)) {
|
|
684
701
|
this.emit('peer_status', {
|
|
685
|
-
status: payload === '
|
|
702
|
+
status: payload === 'AGENT_ONLINE' ? 'online' : 'warning',
|
|
686
703
|
timestamp: msg.timestamp,
|
|
687
704
|
});
|
|
705
|
+
return;
|
|
688
706
|
}
|
|
689
|
-
|
|
690
|
-
//
|
|
691
|
-
this.
|
|
707
|
+
if (payload === 'UNPAIRED') {
|
|
708
|
+
// Permanent disconnection — clear session, stop reconnecting
|
|
709
|
+
this.sessionStore.clear().catch(() => { });
|
|
710
|
+
this.emit('unpaired');
|
|
711
|
+
this.transport?.disconnect();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (SESSION_FATAL.has(payload)) {
|
|
715
|
+
// Session gone — clear and let reconnect do fresh pairing
|
|
716
|
+
this.sessionStore.clear().catch(() => { });
|
|
717
|
+
return;
|
|
692
718
|
}
|
|
719
|
+
// Unknown payload — log but don't trigger re-key
|
|
720
|
+
// (prevents infinite re-key loops from future server messages)
|
|
693
721
|
}
|
|
694
722
|
});
|
|
695
723
|
this.transport.on('open', () => {
|
package/dist/chat/ChatHandler.js
CHANGED
|
@@ -101,10 +101,15 @@ export class PairingProtocol extends EventEmitter {
|
|
|
101
101
|
reject(err);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
//
|
|
105
|
-
if (msg.type === 'CLOSE') {
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Server messages — only fatal errors reject pairing
|
|
105
|
+
if (msg.type === 'CLOSE' || msg.type === 'STATUS') {
|
|
106
|
+
const payload = typeof msg.payload === 'string' ? msg.payload : '';
|
|
107
|
+
const FATAL = ['SESSION_NOT_FOUND', 'INVALID_CODE', 'MISSING_CODE', 'NO_PAIRING', 'UNPAIRED'];
|
|
108
|
+
if (FATAL.includes(payload)) {
|
|
109
|
+
cleanup();
|
|
110
|
+
reject(new Error(`Relay server closed: ${payload}`));
|
|
111
|
+
}
|
|
112
|
+
// Non-fatal (AGENT_WARNING, AGENT_ONLINE, DESKTOP_DISCONNECTED, STATUS) — ignore
|
|
108
113
|
}
|
|
109
114
|
};
|
|
110
115
|
this.transport.on('message', onMessage);
|
|
@@ -202,13 +207,18 @@ export class PairingProtocol extends EventEmitter {
|
|
|
202
207
|
}
|
|
203
208
|
// PAIRED notification from server — agent has joined (initiator mode)
|
|
204
209
|
// No action needed, just wait for agent's HELLO with pubkey
|
|
205
|
-
//
|
|
206
|
-
if (msg.type === 'CLOSE') {
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// Server messages — only fatal errors reject pairing
|
|
211
|
+
if (msg.type === 'CLOSE' || msg.type === 'STATUS') {
|
|
212
|
+
const payload = typeof msg.payload === 'string' ? msg.payload : '';
|
|
213
|
+
const FATAL = ['SESSION_NOT_FOUND', 'INVALID_CODE', 'MISSING_CODE', 'NO_PAIRING', 'UNPAIRED'];
|
|
214
|
+
if (FATAL.includes(payload)) {
|
|
215
|
+
cleanup();
|
|
216
|
+
const reason = payload === 'INVALID_CODE'
|
|
217
|
+
? `Invalid pairing code: ****`
|
|
218
|
+
: `Relay server closed: ${payload}`;
|
|
219
|
+
reject(new Error(reason));
|
|
220
|
+
}
|
|
221
|
+
// Non-fatal — ignore
|
|
212
222
|
}
|
|
213
223
|
};
|
|
214
224
|
this.transport.on('message', onMessage);
|
package/dist/types.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface RelayMessage {
|
|
|
12
12
|
version: typeof RELAY_PROTOCOL_VERSION;
|
|
13
13
|
session_id: string;
|
|
14
14
|
msg_id: string;
|
|
15
|
-
type: 'HELLO' | 'DATA' | 'PING' | 'CLOSE';
|
|
15
|
+
type: 'HELLO' | 'DATA' | 'PING' | 'CLOSE' | 'STATUS' | 'PAIRED';
|
|
16
16
|
pubkey?: string;
|
|
17
17
|
iv?: string;
|
|
18
18
|
payload?: string;
|
package/package.json
CHANGED