echoclaw-relay-agent 0.9.6 → 0.9.9

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.
@@ -35,6 +35,15 @@ export declare class RelayAgent extends EventEmitter {
35
35
  private readonly config;
36
36
  private readonly subscribers;
37
37
  private gatewayManager;
38
+ private _rekeyListener;
39
+ /** Ephemeral keypair generated on transport reconnect for proactive HELLO.
40
+ * Reused by the passive re-key listener to avoid glare (both sides generate
41
+ * different keys → ECDH mismatch). Cleared after successful re-key. */
42
+ private _proactiveKeyPair;
43
+ /** Guard against concurrent re-key attempts. If two desktop HELLOs arrive
44
+ * in rapid succession, the second must wait — otherwise both enter the async
45
+ * handler, generate different keypairs, and cause ECDH mismatch. */
46
+ private _rekeyInFlight;
38
47
  constructor(config: RelayAgentConfig);
39
48
  /** Current connection status. */
40
49
  get status(): ClientStatus;
@@ -59,5 +68,11 @@ export declare class RelayAgent extends EventEmitter {
59
68
  private onPaired;
60
69
  private setupDataHandler;
61
70
  private setupTransportEvents;
71
+ /**
72
+ * Install a persistent listener that responds to desktop HELLO messages
73
+ * with a fresh ECDH handshake. This allows re-keying at any time,
74
+ * not just on transport reconnect.
75
+ */
76
+ private installRekeyListener;
62
77
  private setStatus;
63
78
  }
@@ -16,7 +16,7 @@
16
16
  * await agent.send({ type: 'greeting', text: 'Hello from OpenClaw' });
17
17
  */
18
18
  import { EventEmitter } from 'node:events';
19
- // @echoclaw/crypto used via FrameCrypto and PairingProtocol
19
+ import { generateKeyPair, completeHandshake, toBase64, fromBase64, } from 'echoclaw-crypto';
20
20
  import { RelayTransport } from './relay/RelayTransport.js';
21
21
  import { PairingProtocol } from './relay/PairingProtocol.js';
22
22
  import { SessionStore } from './relay/SessionStore.js';
@@ -127,6 +127,30 @@ export class RelayAgent extends EventEmitter {
127
127
  writable: true,
128
128
  value: null
129
129
  });
130
+ Object.defineProperty(this, "_rekeyListener", {
131
+ enumerable: true,
132
+ configurable: true,
133
+ writable: true,
134
+ value: null
135
+ });
136
+ /** Ephemeral keypair generated on transport reconnect for proactive HELLO.
137
+ * Reused by the passive re-key listener to avoid glare (both sides generate
138
+ * different keys → ECDH mismatch). Cleared after successful re-key. */
139
+ Object.defineProperty(this, "_proactiveKeyPair", {
140
+ enumerable: true,
141
+ configurable: true,
142
+ writable: true,
143
+ value: null
144
+ });
145
+ /** Guard against concurrent re-key attempts. If two desktop HELLOs arrive
146
+ * in rapid succession, the second must wait — otherwise both enter the async
147
+ * handler, generate different keypairs, and cause ECDH mismatch. */
148
+ Object.defineProperty(this, "_rekeyInFlight", {
149
+ enumerable: true,
150
+ configurable: true,
151
+ writable: true,
152
+ value: false
153
+ });
130
154
  this.config = config;
131
155
  this.sessionStore = new SessionStore(config.sessionPath);
132
156
  // V2: Create gateway manager if enabled
@@ -225,6 +249,9 @@ export class RelayAgent extends EventEmitter {
225
249
  this.sessionKey = null;
226
250
  this.frameCrypto = null;
227
251
  this._dataHandler = null;
252
+ this._rekeyListener = null;
253
+ this._proactiveKeyPair = null;
254
+ this._rekeyInFlight = false;
228
255
  this.setStatus('disconnected');
229
256
  }
230
257
  // ── Private ────────────────────────────────────────────────
@@ -267,7 +294,10 @@ export class RelayAgent extends EventEmitter {
267
294
  this.pairing = new PairingProtocol(this.transport);
268
295
  this.transport.connect();
269
296
  try {
270
- const result = await this.pairing.pairAsAgent();
297
+ // Pass saved pairing code so HKDF info matches the desktop side.
298
+ // Without this, agent derives with undefined while desktop derives
299
+ // with the saved code → AES-GCM key mismatch → decrypt failure.
300
+ const result = await this.pairing.pairAsAgent(session.pairingCode || undefined);
271
301
  await this.onPaired(result);
272
302
  }
273
303
  catch (err) {
@@ -308,6 +338,10 @@ export class RelayAgent extends EventEmitter {
308
338
  lastConnected: new Date().toISOString(),
309
339
  });
310
340
  this.setupDataHandler();
341
+ // Re-install re-key listener so future desktop HELLOs are handled.
342
+ // Without this, after a re-key, the listener references the old transport
343
+ // message handler and subsequent re-keys silently fail (Reviewer #3 finding).
344
+ this.installRekeyListener();
311
345
  this.setStatus('connected');
312
346
  this.emit('paired', {
313
347
  pairingCode: result.pairingCode,
@@ -387,63 +421,136 @@ export class RelayAgent extends EventEmitter {
387
421
  this.transport.on('error', (err) => {
388
422
  this.emit('error', err);
389
423
  });
390
- this.transport.on('open', () => {
391
- if (this._paired && this.transport) {
392
- // Transport reconnected after prior successful pairing —
393
- // redo ECDH because desktop may have new ephemeral keys.
394
- // Cancel any in-flight pairing but KEEP old crypto state
395
- // so data handler can still decrypt until re-key succeeds.
396
- this._activePairing?.abort();
397
- // CRITICAL: Do NOT null sessionKey/frameCrypto here.
398
- // If re-key fails, we want the data handler to keep working
399
- // with the old keys rather than dropping all messages.
400
- this.setStatus('connecting');
401
- const pairing = new PairingProtocol(this.transport);
402
- this._activePairing = pairing;
403
- // Use shorter timeout for re-key (30s) vs initial pairing (10 min).
404
- // If desktop is online, ECDH completes in <1s; 30s gives ample margin.
405
- pairing.pairAsAgent(undefined, 30000)
406
- .then(result => {
407
- this._rekeyRetryCount = 0; // Reset on success
408
- this.onPaired(result);
409
- // V2: Reattach gateway callbacks after reconnect
410
- if (this.gatewayManager) {
411
- this.gatewayManager.onReconnected(async (r) => {
412
- try {
413
- await this.send(r);
414
- }
415
- catch { /* send may fail */ }
416
- });
417
- }
418
- })
419
- .catch(err => {
420
- this._activePairing = null;
421
- this._rekeyRetryCount++;
422
- // Circuit breaker: after 5 consecutive failures, force full reconnect cycle
423
- // (disconnect + restart resets the relay server connection from scratch)
424
- if (this._rekeyRetryCount >= 5) {
425
- this._rekeyRetryCount = 0; // Reset for next cycle
426
- this.emit('error', new Error(`Re-key failed 5 times, forcing fresh reconnect`));
427
- if (this.transport) {
428
- this.transport.disconnect();
429
- this.transport.restart();
430
- }
431
- return;
432
- }
433
- // Re-key failed — keep old crypto intact, mark as connected
434
- // (data handler still works with old keys if desktop didn't re-key either)
435
- if (this._paired && this.sessionKey) {
436
- this.setStatus('connected');
437
- }
438
- // If WS is still OPEN, force reconnect to trigger fresh on('open')
439
- if (this.transport?.isConnected) {
424
+ this.transport.on('open', async () => {
425
+ if (!this.transport || !this._paired || !this.sessionKey)
426
+ return;
427
+ // Transport reconnected after a disconnect.
428
+ // Don't emit 'connected' yet the old sessionKey is stale because
429
+ // desktop will re-key. Emitting 'connected' would let consumers call
430
+ // send() with the old key, causing decrypt failures on the other side.
431
+ // Status stays 'reconnecting' until onPaired() completes the re-key.
432
+ //
433
+ // Send a proactive HELLO so the desktop knows we're back and can
434
+ // start re-keying immediately — prevents deadlock where both sides
435
+ // wait for the other to initiate.
436
+ //
437
+ // The keypair is stored in _proactiveKeyPair so the passive re-key
438
+ // listener can reuse the SAME keypair when desktop responds with its
439
+ // own HELLO. ECDH is symmetric: ECDH(a, B) == ECDH(b, A), so both
440
+ // sides derive the same session key regardless of message ordering.
441
+ try {
442
+ this._proactiveKeyPair = await generateKeyPair();
443
+ this.transport.send(this.transport.buildMessage('HELLO', this.sessionId, 'agent', {
444
+ pubkey: toBase64(this._proactiveKeyPair.publicKey),
445
+ }));
446
+ }
447
+ catch (err) {
448
+ // Non-fatal — passive listener still works as fallback
449
+ this._proactiveKeyPair = null;
450
+ this.emit('error', new Error(`Proactive HELLO failed: ${err instanceof Error ? err.message : err}`));
451
+ }
452
+ // Timeout: if desktop doesn't respond within 30s, clear stale keypair
453
+ // and force reconnect so we get a fresh attempt.
454
+ setTimeout(() => {
455
+ if (this._proactiveKeyPair) {
456
+ this._proactiveKeyPair = null;
457
+ // If still not re-keyed, force reconnect
458
+ if (this._status === 'reconnecting' && this.transport) {
440
459
  this.transport.disconnect();
441
- this.transport.restart();
442
460
  }
443
- this.emit('error', err);
461
+ }
462
+ }, 30000);
463
+ // V2: Reattach gateway callbacks after reconnect
464
+ if (this.gatewayManager) {
465
+ this.gatewayManager.onReconnected(async (r) => {
466
+ try {
467
+ await this.send(r);
468
+ }
469
+ catch { /* send may fail */ }
444
470
  });
445
471
  }
446
472
  });
473
+ // Passive re-key listener: always respond to desktop HELLO messages,
474
+ // even when already paired. This fixes the critical bug where:
475
+ // - CLI→service transition: desktop reconnects, sends HELLO, but
476
+ // agent has no active PairingProtocol to respond → timeout → circuit breaker.
477
+ // - Desktop restart: resumeSession() sends HELLO, agent doesn't respond.
478
+ //
479
+ // The listener is persistent — it stays active for the lifetime of the transport.
480
+ // It only triggers when _paired=true AND no _activePairing is in-flight.
481
+ this.installRekeyListener();
482
+ }
483
+ /**
484
+ * Install a persistent listener that responds to desktop HELLO messages
485
+ * with a fresh ECDH handshake. This allows re-keying at any time,
486
+ * not just on transport reconnect.
487
+ */
488
+ installRekeyListener() {
489
+ if (!this.transport)
490
+ return;
491
+ // Remove previous listener to prevent accumulation
492
+ if (this._rekeyListener) {
493
+ this.transport.removeListener('message', this._rekeyListener);
494
+ }
495
+ this._rekeyListener = async (msg) => {
496
+ // Only respond to desktop HELLO with pubkey when:
497
+ // 1. We're already paired (initial pairing is handled by PairingProtocol)
498
+ // 2. No active PairingProtocol in-flight (avoid competing listeners)
499
+ // 3. Transport is still alive (null guard per Reviewer #3)
500
+ if (msg.type !== 'HELLO' ||
501
+ msg.sender_role !== 'desktop' ||
502
+ !msg.pubkey ||
503
+ !this._paired ||
504
+ this._activePairing ||
505
+ !this.transport ||
506
+ this._rekeyInFlight // Prevent concurrent re-key (Reviewer #1 P1)
507
+ ) {
508
+ return;
509
+ }
510
+ this._rekeyInFlight = true;
511
+ try {
512
+ // Reuse proactive keypair if available (sent during on('open')),
513
+ // otherwise generate a fresh one. This avoids "glare" — if both
514
+ // sides independently generate keypairs, the ECDH results won't
515
+ // match because each side would use a different agent pubkey.
516
+ let keyPair;
517
+ let alreadySentHello = false;
518
+ if (this._proactiveKeyPair) {
519
+ keyPair = this._proactiveKeyPair;
520
+ this._proactiveKeyPair = null; // Consume — single use
521
+ alreadySentHello = true; // HELLO was already sent in on('open')
522
+ }
523
+ else {
524
+ keyPair = await generateKeyPair();
525
+ }
526
+ // Only send HELLO if we haven't already (proactive path)
527
+ if (!alreadySentHello && this.transport) {
528
+ this.transport.send(this.transport.buildMessage('HELLO', this.sessionId, 'agent', {
529
+ pubkey: toBase64(keyPair.publicKey),
530
+ }));
531
+ }
532
+ // Complete ECDH with desktop's public key
533
+ const theirPub = fromBase64(msg.pubkey);
534
+ const sessionKey = await completeHandshake(keyPair.privateKey, theirPub, this.pairingCode || undefined);
535
+ // Update crypto state
536
+ this._rekeyRetryCount = 0;
537
+ this._proactiveKeyPair = null; // Ensure cleanup
538
+ await this.onPaired({
539
+ sessionKey,
540
+ sessionId: msg.session_id || this.sessionId,
541
+ pairingCode: this.pairingCode,
542
+ });
543
+ this.emit('rekeyed', { sessionId: this.sessionId });
544
+ }
545
+ catch (err) {
546
+ this._proactiveKeyPair = null; // Cleanup on error
547
+ this.emit('error', new Error(`Passive re-key failed: ${err instanceof Error ? err.message : err}`));
548
+ }
549
+ finally {
550
+ this._rekeyInFlight = false;
551
+ }
552
+ };
553
+ this.transport.on('message', this._rekeyListener);
447
554
  }
448
555
  setStatus(status) {
449
556
  if (this._status !== status) {
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { RelayAgent } from './RelayAgent.js';
13
13
  import { getServiceManager } from './service/platform.js';
14
14
  import { ChatHandler } from './chat/ChatHandler.js';
15
+ import { handleMemorySync } from './gateway/WorkspaceSync.js';
15
16
  const DEFAULT_RELAY = 'wss://relay.echoclaw.me';
16
17
  // ── ASCII Banners ───────────────────────────────────────────
17
18
  const RESET = '\x1b[0m';
@@ -366,7 +367,23 @@ async function runDaemon(relay, code, gateway, bridgePort) {
366
367
  const payloadType = payload?.type;
367
368
  const isChatMsg = payloadType === 'chat' || payloadType === 'system_prompt' ||
368
369
  payloadType === 'chat_abort' || payloadType === 'chat_history';
369
- if (isChatMsg) {
370
+ if (payloadType === 'memory_sync') {
371
+ // Desktop sends guide content for OpenClaw workspace
372
+ console.log(` [memory_sync] version=${payload.version}`);
373
+ try {
374
+ const ack = await handleMemorySync(payload);
375
+ console.log(` [memory_sync] ${ack.status} (v${ack.version})`);
376
+ await agent.send(ack);
377
+ }
378
+ catch (err) {
379
+ console.error(` [memory_sync] error: ${err.message}`);
380
+ try {
381
+ await agent.send({ type: 'memory_sync_ack', version: payload.version, status: 'error', error: err.message });
382
+ }
383
+ catch { /* send may fail */ }
384
+ }
385
+ }
386
+ else if (isChatMsg) {
370
387
  console.log(` [message] type=${payloadType}`);
371
388
  // Route chat-related messages to ChatHandler
372
389
  // sendBack callback is for immediate error responses; async events use persistent callback
@@ -0,0 +1,28 @@
1
+ /**
2
+ * WorkspaceSync — Write memory/guide files to OpenClaw workspace.
3
+ *
4
+ * Handles `memory_sync` messages from the desktop client:
5
+ * 1. Check version — skip if already written
6
+ * 2. Write MEMORY_SECTION.md, ECHOCLAW_DEV_GUIDE.md, CLAWAPP_SPEC.md
7
+ * 3. Update/append the EchoClaw section in MEMORY.md
8
+ * 4. Write version marker
9
+ * 5. Reply with ack
10
+ */
11
+ export interface MemorySyncPayload {
12
+ type: 'memory_sync';
13
+ version: string;
14
+ memory_section: string;
15
+ dev_guide: string;
16
+ spec: string;
17
+ }
18
+ export interface MemorySyncAck {
19
+ type: 'memory_sync_ack';
20
+ version: string;
21
+ status: 'written' | 'skipped' | 'error';
22
+ error?: string;
23
+ }
24
+ /**
25
+ * Handle a memory_sync message: write guide files to OpenClaw workspace.
26
+ * Returns the ack payload to send back to the desktop.
27
+ */
28
+ export declare function handleMemorySync(payload: MemorySyncPayload): Promise<MemorySyncAck>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * WorkspaceSync — Write memory/guide files to OpenClaw workspace.
3
+ *
4
+ * Handles `memory_sync` messages from the desktop client:
5
+ * 1. Check version — skip if already written
6
+ * 2. Write MEMORY_SECTION.md, ECHOCLAW_DEV_GUIDE.md, CLAWAPP_SPEC.md
7
+ * 3. Update/append the EchoClaw section in MEMORY.md
8
+ * 4. Write version marker
9
+ * 5. Reply with ack
10
+ */
11
+ import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ const WORKSPACE_DIR = join(homedir(), '.openclaw', 'workspace');
15
+ const VERSION_FILE = '.echoclaw-guide-version';
16
+ // Exact heading used in MEMORY.md for the EchoClaw section.
17
+ const SECTION_HEADING = '## EchoClaw 应用平台';
18
+ // Regex built from SECTION_HEADING to prevent constant/regex drift.
19
+ // Handles both LF and CRLF line endings to avoid data loss on Windows.
20
+ const SECTION_RE = new RegExp(SECTION_HEADING.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
21
+ '[\\s\\S]*?(?=\\r?\\n## |$)');
22
+ // Mutex: prevent concurrent memory_sync from corrupting MEMORY.md
23
+ let _syncInFlight = false;
24
+ /**
25
+ * Handle a memory_sync message: write guide files to OpenClaw workspace.
26
+ * Returns the ack payload to send back to the desktop.
27
+ */
28
+ export async function handleMemorySync(payload) {
29
+ // Input validation (P1 #3: prevent undefined fields from bricking state)
30
+ if (!payload.version || typeof payload.version !== 'string') {
31
+ return { type: 'memory_sync_ack', version: payload.version ?? '', status: 'error', error: 'missing version' };
32
+ }
33
+ if (typeof payload.memory_section !== 'string' ||
34
+ typeof payload.dev_guide !== 'string' ||
35
+ typeof payload.spec !== 'string') {
36
+ return { type: 'memory_sync_ack', version: payload.version, status: 'error', error: 'missing content fields' };
37
+ }
38
+ // Concurrency guard (P1 #6: prevent concurrent writes corrupting MEMORY.md)
39
+ if (_syncInFlight) {
40
+ return { type: 'memory_sync_ack', version: payload.version, status: 'skipped' };
41
+ }
42
+ _syncInFlight = true;
43
+ try {
44
+ return await doSync(payload);
45
+ }
46
+ finally {
47
+ _syncInFlight = false;
48
+ }
49
+ }
50
+ async function doSync(payload) {
51
+ const workspacePath = WORKSPACE_DIR;
52
+ try {
53
+ // Ensure workspace directory exists
54
+ await mkdir(workspacePath, { recursive: true });
55
+ const versionFilePath = join(workspacePath, VERSION_FILE);
56
+ // 1. Check existing version — skip if same
57
+ const existingVersion = await readFile(versionFilePath, 'utf-8').catch(() => '');
58
+ if (existingVersion.trim() === payload.version.trim()) {
59
+ return { type: 'memory_sync_ack', version: payload.version, status: 'skipped' };
60
+ }
61
+ // 2. Write individual guide files
62
+ await writeFile(join(workspacePath, 'MEMORY_SECTION.md'), payload.memory_section, 'utf-8');
63
+ await writeFile(join(workspacePath, 'ECHOCLAW_DEV_GUIDE.md'), payload.dev_guide, 'utf-8');
64
+ await writeFile(join(workspacePath, 'CLAWAPP_SPEC.md'), payload.spec, 'utf-8');
65
+ // 3. Update MEMORY.md — replace existing EchoClaw section or append
66
+ const memoryPath = join(workspacePath, 'MEMORY.md');
67
+ const memory = await readFile(memoryPath, 'utf-8').catch(() => '');
68
+ if (memory.includes(SECTION_HEADING)) {
69
+ // Replace existing section using SECTION_RE (handles CRLF).
70
+ // Use function form to prevent $& / $' / $` in content being interpreted.
71
+ const updated = memory.replace(SECTION_RE, () => payload.memory_section);
72
+ await writeFile(memoryPath, updated, 'utf-8');
73
+ }
74
+ else if (memory.length > 0) {
75
+ // Append to existing MEMORY.md with blank line separator
76
+ await appendFile(memoryPath, '\n\n' + payload.memory_section, 'utf-8');
77
+ }
78
+ else {
79
+ // Create new MEMORY.md
80
+ await writeFile(memoryPath, payload.memory_section, 'utf-8');
81
+ }
82
+ // 4. Write version marker (last — so partial failures re-trigger on next attempt)
83
+ await writeFile(versionFilePath, payload.version + '\n', 'utf-8');
84
+ return { type: 'memory_sync_ack', version: payload.version, status: 'written' };
85
+ }
86
+ catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ return { type: 'memory_sync_ack', version: payload.version, status: 'error', error: msg };
89
+ }
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.9.6",
3
+ "version": "0.9.9",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",