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.
- package/dist/RelayAgent.d.ts +2 -0
- package/dist/RelayAgent.js +55 -14
- package/dist/RelayClient.d.ts +3 -0
- package/dist/RelayClient.js +51 -1
- package/dist/cli.js +33 -1
- package/dist/relay/PairingProtocol.d.ts +3 -0
- package/dist/relay/PairingProtocol.js +17 -0
- package/package.json +1 -1
package/dist/RelayAgent.d.ts
CHANGED
package/dist/RelayAgent.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
}
|
package/dist/RelayClient.d.ts
CHANGED
|
@@ -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;
|
package/dist/RelayClient.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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