@agentvault/agentvault 0.14.30 → 0.15.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.
Files changed (57) hide show
  1. package/README.md +3 -1
  2. package/dist/account-config.js +60 -0
  3. package/dist/account-config.js.map +1 -0
  4. package/dist/channel.d.ts +5 -0
  5. package/dist/channel.d.ts.map +1 -1
  6. package/dist/channel.js +3411 -0
  7. package/dist/channel.js.map +1 -0
  8. package/dist/cli.js +253 -72006
  9. package/dist/cli.js.map +1 -7
  10. package/dist/create-agent.js +314 -0
  11. package/dist/create-agent.js.map +1 -0
  12. package/dist/crypto-helpers.js +4 -0
  13. package/dist/crypto-helpers.js.map +1 -0
  14. package/dist/doctor.js +415 -0
  15. package/dist/doctor.js.map +1 -0
  16. package/dist/fetch-interceptor.js +213 -0
  17. package/dist/fetch-interceptor.js.map +1 -0
  18. package/dist/gateway-send.js +114 -0
  19. package/dist/gateway-send.js.map +1 -0
  20. package/dist/http-handlers.js +131 -0
  21. package/dist/http-handlers.js.map +1 -0
  22. package/dist/index.js +24 -76340
  23. package/dist/index.js.map +1 -7
  24. package/dist/mcp-handlers.js +48 -0
  25. package/dist/mcp-handlers.js.map +1 -0
  26. package/dist/mcp-server.js +192 -0
  27. package/dist/mcp-server.js.map +1 -0
  28. package/dist/openclaw-compat.js +94 -0
  29. package/dist/openclaw-compat.js.map +1 -0
  30. package/dist/openclaw-entry.d.ts.map +1 -1
  31. package/dist/openclaw-entry.js +1204 -1414
  32. package/dist/openclaw-entry.js.map +1 -7
  33. package/dist/openclaw-plugin.js +297 -0
  34. package/dist/openclaw-plugin.js.map +1 -0
  35. package/dist/openclaw-types.js +13 -0
  36. package/dist/openclaw-types.js.map +1 -0
  37. package/dist/setup.js +460 -0
  38. package/dist/setup.js.map +1 -0
  39. package/dist/skill-invoker.js +100 -0
  40. package/dist/skill-invoker.js.map +1 -0
  41. package/dist/skill-manifest.js +249 -0
  42. package/dist/skill-manifest.js.map +1 -0
  43. package/dist/skill-telemetry.js +146 -0
  44. package/dist/skill-telemetry.js.map +1 -0
  45. package/dist/skills-publish.js +133 -0
  46. package/dist/skills-publish.js.map +1 -0
  47. package/dist/state.js +178 -0
  48. package/dist/state.js.map +1 -0
  49. package/dist/transport.js +43 -0
  50. package/dist/transport.js.map +1 -0
  51. package/dist/types.d.ts +2 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/workspace-handlers.js +177 -0
  56. package/dist/workspace-handlers.js.map +1 -0
  57. package/package.json +1 -1
@@ -0,0 +1,3411 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { createServer } from "node:http";
3
+ import { randomUUID } from "node:crypto";
4
+ import { writeFile, mkdir } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { readFile } from "node:fs/promises";
7
+ import sodium from "libsodium-wrappers-sumo";
8
+ import WebSocket from "ws";
9
+ import { generateIdentityKeypair, generateEphemeralKeypair, computeFingerprint, createProofOfPossession, performX3DH, DoubleRatchet, decryptFile, encryptFile, computeFileDigest, ScanEngine, TelemetryReporter, } from "@agentvault/crypto";
10
+ import { hexToBytes, bytesToHex, base64ToBytes, bytesToBase64, encryptedMessageToTransport, transportToEncryptedMessage, SenderKeyChain, SenderKeyState, } from "./crypto-helpers.js";
11
+ import { saveState, loadState, clearState, backupState, restoreState, uploadBackupToServer } from "./state.js";
12
+ import { enrollDevice, pollDeviceStatus, activateDevice, } from "./transport.js";
13
+ const POLL_INTERVAL_MS = 6_000; // 6s — safely below the 1/5sec server-side rate limit
14
+ const RECONNECT_BASE_MS = 1_000;
15
+ const RECONNECT_MAX_MS = 30_000;
16
+ const PENDING_POLL_INTERVAL_MS = 15_000;
17
+ /**
18
+ * Migrates legacy single-session persisted state to the new
19
+ * multi-session format. If the state is already in the new format,
20
+ * returns it as-is.
21
+ */
22
+ function migratePersistedState(raw) {
23
+ // New format: has `sessions` and `primaryConversationId`
24
+ if (raw.sessions && raw.primaryConversationId) {
25
+ return raw;
26
+ }
27
+ // Legacy format: single conversationId + ratchetState
28
+ const legacy = raw;
29
+ return {
30
+ deviceId: legacy.deviceId,
31
+ deviceJwt: legacy.deviceJwt,
32
+ primaryConversationId: legacy.conversationId,
33
+ sessions: {
34
+ [legacy.conversationId]: {
35
+ ownerDeviceId: "",
36
+ ratchetState: legacy.ratchetState,
37
+ },
38
+ },
39
+ identityKeypair: legacy.identityKeypair,
40
+ ephemeralKeypair: legacy.ephemeralKeypair,
41
+ fingerprint: legacy.fingerprint,
42
+ lastMessageTimestamp: legacy.lastMessageTimestamp,
43
+ messageHistory: [],
44
+ };
45
+ }
46
+ export class SecureChannel extends EventEmitter {
47
+ config;
48
+ _state = "idle";
49
+ _deviceId = null;
50
+ _fingerprint = null;
51
+ _primaryConversationId = "";
52
+ _deviceJwt = null;
53
+ _sessions = new Map();
54
+ _ws = null;
55
+ _pollTimer = null;
56
+ _reconnectAttempt = 0;
57
+ _reconnectTimer = null;
58
+ _rapidDisconnects = 0;
59
+ _lastWsOpenTime = 0;
60
+ _pingTimer = null;
61
+ _lastServerMessage = 0;
62
+ _pendingAcks = [];
63
+ _ackTimer = null;
64
+ _stopped = false;
65
+ _persisted = null;
66
+ _httpServer = null;
67
+ _pollFallbackTimer = null;
68
+ _heartbeatTimer = null;
69
+ _heartbeatCallback = null;
70
+ _heartbeatIntervalSeconds = 0;
71
+ _wakeDetectorTimer = null;
72
+ _lastWakeTick = Date.now();
73
+ _pendingPollTimer = null;
74
+ _syncMessageIds = null;
75
+ /** Sender Key chains — own chain per room for O(1) encryption */
76
+ _senderKeyChains = new Map();
77
+ /** Sender Key peer state — peer chains per room for decryption */
78
+ _senderKeyStates = new Map();
79
+ /** Queued A2A messages for responder channels not yet activated (no first initiator message received). */
80
+ _a2aPendingQueue = {};
81
+ /** Dedup buffer for A2A message IDs (prevents double-delivery via direct + Redis) */
82
+ _a2aSeenMessageIds = new Set();
83
+ static A2A_SEEN_MAX = 500;
84
+ _scanEngine = null;
85
+ _scanRuleSetVersion = 0;
86
+ _telemetryReporter = null;
87
+ /** Topic ID from the most recent inbound message — used as fallback for replies. */
88
+ _lastIncomingTopicId;
89
+ /** Room ID from the most recent inbound room message — used as fallback for HTTP /send replies. */
90
+ _lastInboundRoomId;
91
+ /** Rate-limit: last resync_request timestamp per conversation (5-min cooldown). */
92
+ _lastResyncRequest = new Map();
93
+ /** Debounce timer for server backup uploads (60s). */
94
+ _serverBackupTimer = null;
95
+ _serverBackupRunning = false;
96
+ // Liveness detection: server sends app-level {"event":"ping"} every 30s.
97
+ // We check every 30s; if no data received in 90s (3 missed pings), connection is dead.
98
+ static PING_INTERVAL_MS = 30_000;
99
+ static SILENCE_TIMEOUT_MS = 90_000;
100
+ static POLL_FALLBACK_INTERVAL_MS = 30_000; // 30s when messages found
101
+ static POLL_FALLBACK_IDLE_MS = 60_000; // 60s when idle
102
+ constructor(config) {
103
+ super();
104
+ this.config = config;
105
+ }
106
+ get state() {
107
+ return this._state;
108
+ }
109
+ get deviceId() {
110
+ return this._deviceId;
111
+ }
112
+ get fingerprint() {
113
+ return this._fingerprint;
114
+ }
115
+ /** Returns the primary conversation ID (backward-compatible). */
116
+ get conversationId() {
117
+ return this._primaryConversationId || null;
118
+ }
119
+ /** Returns all active conversation IDs. */
120
+ get conversationIds() {
121
+ return Array.from(this._sessions.keys());
122
+ }
123
+ /** Returns the number of active sessions. */
124
+ get sessionCount() {
125
+ return this._sessions.size;
126
+ }
127
+ /** Room ID from the most recent inbound room message (for HTTP /send fallback). */
128
+ get lastInboundRoomId() {
129
+ return this._lastInboundRoomId;
130
+ }
131
+ /** Returns all persisted room IDs and names (for outbound target registration). */
132
+ get roomIds() {
133
+ if (!this._persisted?.rooms)
134
+ return [];
135
+ return Object.values(this._persisted.rooms).map((r) => ({ roomId: r.roomId, name: r.name }));
136
+ }
137
+ /** Returns hub addresses of all persisted A2A peer channels. */
138
+ get a2aPeerAddresses() {
139
+ if (!this._persisted?.a2aChannels)
140
+ return [];
141
+ return Object.values(this._persisted.a2aChannels).map((ch) => ch.hubAddress);
142
+ }
143
+ /** Resolves an A2A channel ID to the peer's hub address, or null if not found. */
144
+ resolveA2AChannelHub(channelId) {
145
+ const entry = this._persisted?.a2aChannels?.[channelId];
146
+ return entry?.hubAddress ?? null;
147
+ }
148
+ /** Returns the TelemetryReporter instance (available after WebSocket connect). */
149
+ get telemetry() {
150
+ return this._telemetryReporter;
151
+ }
152
+ async start() {
153
+ this._stopped = false;
154
+ await sodium.ready;
155
+ // Check for persisted state (may be legacy or new format)
156
+ const raw = await loadState(this.config.dataDir);
157
+ if (raw) {
158
+ await backupState(this.config.dataDir);
159
+ this._persisted = migratePersistedState(raw);
160
+ if (!this._persisted.messageHistory) {
161
+ this._persisted.messageHistory = [];
162
+ }
163
+ this._deviceId = this._persisted.deviceId;
164
+ this._deviceJwt = this._persisted.deviceJwt;
165
+ this._primaryConversationId = this._persisted.primaryConversationId;
166
+ this._fingerprint = this._persisted.fingerprint;
167
+ // Restore sticky room context for proactive send routing
168
+ this._lastInboundRoomId = this._persisted.lastInboundRoomId;
169
+ // Restore all ratchet sessions
170
+ for (const [convId, sessionData] of Object.entries(this._persisted.sessions)) {
171
+ if (sessionData.ratchetState) {
172
+ const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
173
+ this._sessions.set(convId, {
174
+ ownerDeviceId: sessionData.ownerDeviceId,
175
+ ratchet,
176
+ activated: sessionData.activated ?? false,
177
+ });
178
+ }
179
+ }
180
+ // Restore sender key chains/states from persisted rooms
181
+ if (this._persisted.rooms) {
182
+ for (const room of Object.values(this._persisted.rooms)) {
183
+ if (room.ownSenderKeyChain) {
184
+ try {
185
+ this._senderKeyChains.set(room.roomId, SenderKeyChain.deserialize(room.ownSenderKeyChain));
186
+ }
187
+ catch { }
188
+ }
189
+ if (room.peerSenderKeyState) {
190
+ try {
191
+ this._senderKeyStates.set(room.roomId, SenderKeyState.deserialize(room.peerSenderKeyState));
192
+ }
193
+ catch { }
194
+ }
195
+ }
196
+ }
197
+ this._connect();
198
+ return;
199
+ }
200
+ // Try restoring from backup before enrolling
201
+ const restored = await restoreState(this.config.dataDir);
202
+ if (restored) {
203
+ console.log("[SecureChannel] Restored state from backup");
204
+ const restoredRaw = await loadState(this.config.dataDir);
205
+ if (restoredRaw) {
206
+ this._persisted = migratePersistedState(restoredRaw);
207
+ if (!this._persisted.messageHistory)
208
+ this._persisted.messageHistory = [];
209
+ this._deviceId = this._persisted.deviceId;
210
+ this._deviceJwt = this._persisted.deviceJwt;
211
+ this._primaryConversationId = this._persisted.primaryConversationId;
212
+ this._fingerprint = this._persisted.fingerprint;
213
+ for (const [convId, sd] of Object.entries(this._persisted.sessions)) {
214
+ if (sd.ratchetState) {
215
+ this._sessions.set(convId, {
216
+ ownerDeviceId: sd.ownerDeviceId,
217
+ ratchet: DoubleRatchet.deserialize(sd.ratchetState),
218
+ activated: sd.activated ?? false,
219
+ });
220
+ }
221
+ }
222
+ this._connect();
223
+ return;
224
+ }
225
+ }
226
+ // Full lifecycle: enroll -> poll -> activate -> connect
227
+ await this._enroll();
228
+ }
229
+ /**
230
+ * Fetch scan rules from the server and load them into the ScanEngine.
231
+ */
232
+ async _fetchScanRules() {
233
+ if (!this._scanEngine)
234
+ return;
235
+ try {
236
+ const resp = await fetch(`${this.config.apiUrl}/api/v1/scan-rules`, {
237
+ headers: { Authorization: `Bearer ${this._persisted?.deviceJwt}` },
238
+ });
239
+ if (!resp.ok)
240
+ return;
241
+ const data = await resp.json();
242
+ if (data.rule_set_version !== this._scanRuleSetVersion) {
243
+ this._scanEngine.loadRules(data.rules);
244
+ this._scanRuleSetVersion = data.rule_set_version;
245
+ }
246
+ }
247
+ catch (err) {
248
+ console.error("[SecureChannel] Failed to fetch scan rules:", err);
249
+ }
250
+ }
251
+ /**
252
+ * Append a message to persistent history for cross-device replay.
253
+ */
254
+ _appendHistory(sender, text, topicId) {
255
+ if (!this._persisted)
256
+ return;
257
+ if (!this._persisted.messageHistory) {
258
+ this._persisted.messageHistory = [];
259
+ }
260
+ const maxSize = this.config.maxHistorySize ?? 500;
261
+ const entry = {
262
+ sender,
263
+ text,
264
+ ts: new Date().toISOString(),
265
+ };
266
+ if (topicId) {
267
+ entry.topicId = topicId;
268
+ }
269
+ this._persisted.messageHistory.push(entry);
270
+ // Evict oldest entries if over limit
271
+ if (this._persisted.messageHistory.length > maxSize) {
272
+ this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
273
+ }
274
+ }
275
+ /**
276
+ * Encrypt and send a message to ALL owner devices (fanout).
277
+ * Each session gets the same plaintext encrypted independently.
278
+ */
279
+ async send(plaintext, options) {
280
+ // Permanent failures — still throw
281
+ if (this._state === "error" || this._state === "idle") {
282
+ throw new Error("Channel is not ready");
283
+ }
284
+ if (this._sessions.size === 0) {
285
+ throw new Error("No active sessions");
286
+ }
287
+ // Wake agent proactively before sending (fire-and-forget, best-effort)
288
+ import("./openclaw-compat.js")
289
+ .then(({ requestHeartbeatNow }) => requestHeartbeatNow({ reason: "channel-send" }))
290
+ .catch(() => { });
291
+ const targetConvId = options?.conversationId;
292
+ const topicId = options?.topicId ?? this._lastIncomingTopicId ?? this._persisted?.defaultTopicId;
293
+ const messageType = options?.messageType ?? "text";
294
+ const priority = options?.priority ?? "normal";
295
+ const parentSpanId = options?.parentSpanId;
296
+ const envelopeMetadata = options?.metadata;
297
+ // Outbound scan — before encryption
298
+ let scanStatus;
299
+ if (this._scanEngine) {
300
+ const scanResult = this._scanEngine.scanOutbound(plaintext);
301
+ if (scanResult.status === "blocked") {
302
+ this.emit("scan_blocked", { direction: "outbound", violations: scanResult.violations });
303
+ throw new Error(`Message blocked by scan rule: ${scanResult.violations[0]?.rule_name}`);
304
+ }
305
+ scanStatus = scanResult.status; // "clean" or "flagged"
306
+ }
307
+ // Store agent message in history for cross-device replay
308
+ this._appendHistory("agent", plaintext, topicId);
309
+ // Build set of room conversation IDs to exclude from 1:1 send
310
+ const roomConvIds = new Set();
311
+ if (this._persisted?.rooms) {
312
+ for (const room of Object.values(this._persisted.rooms)) {
313
+ for (const cid of room.conversationIds) {
314
+ roomConvIds.add(cid);
315
+ }
316
+ }
317
+ }
318
+ const messageGroupId = randomUUID();
319
+ let sentCount = 0;
320
+ for (const [convId, session] of this._sessions) {
321
+ if (!session.activated)
322
+ continue;
323
+ // Skip room pairwise sessions — those use sendToRoom()
324
+ if (roomConvIds.has(convId))
325
+ continue;
326
+ // If a specific conversation is targeted, skip all others
327
+ if (targetConvId && convId !== targetConvId)
328
+ continue;
329
+ try {
330
+ const encrypted = session.ratchet.encrypt(plaintext);
331
+ const transport = encryptedMessageToTransport(encrypted);
332
+ const msg = {
333
+ convId,
334
+ headerBlob: transport.header_blob,
335
+ ciphertext: transport.ciphertext,
336
+ messageGroupId,
337
+ topicId,
338
+ };
339
+ if (this._state === "ready" && this._ws) {
340
+ // Send immediately
341
+ const payload = {
342
+ conversation_id: msg.convId,
343
+ header_blob: msg.headerBlob,
344
+ ciphertext: msg.ciphertext,
345
+ message_group_id: msg.messageGroupId,
346
+ topic_id: msg.topicId,
347
+ message_type: messageType,
348
+ priority: priority,
349
+ parent_span_id: parentSpanId,
350
+ metadata: scanStatus
351
+ ? { ...(envelopeMetadata ?? {}), scan_status: scanStatus }
352
+ : envelopeMetadata,
353
+ };
354
+ if (this._persisted?.hubAddress) {
355
+ payload.hub_address = this._persisted.hubAddress;
356
+ }
357
+ if (this._persisted?.hubId) {
358
+ payload.sender_hub_id = this._persisted.hubId;
359
+ }
360
+ this._ws.send(JSON.stringify({
361
+ event: "message",
362
+ data: payload,
363
+ }));
364
+ }
365
+ else {
366
+ // Queue for later
367
+ if (!this._persisted.outboundQueue) {
368
+ this._persisted.outboundQueue = [];
369
+ }
370
+ if (this._persisted.outboundQueue.length >= 50) {
371
+ this._persisted.outboundQueue.shift(); // Drop oldest
372
+ console.warn("[SecureChannel] Outbound queue full, dropping oldest message");
373
+ }
374
+ this._persisted.outboundQueue.push(msg);
375
+ console.log(`[SecureChannel] Message queued (state=${this._state}, queue=${this._persisted.outboundQueue.length})`);
376
+ }
377
+ sentCount++;
378
+ }
379
+ catch (err) {
380
+ // Per-session error — log and continue to next session so one
381
+ // stale/broken session doesn't prevent delivery to healthy ones
382
+ console.error(`[SecureChannel] send() failed for conv ${convId.slice(0, 8)}...:`, err);
383
+ }
384
+ }
385
+ if (sentCount === 0 && this._sessions.size > 0) {
386
+ console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
387
+ }
388
+ // Persist all ratchet states after encrypt
389
+ await this._persistState();
390
+ }
391
+ /**
392
+ * Send a typing indicator to all owner devices.
393
+ * Ephemeral (unencrypted metadata), no ratchet advancement.
394
+ */
395
+ sendTyping() {
396
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN)
397
+ return;
398
+ for (const convId of this._sessions.keys()) {
399
+ this._ws.send(JSON.stringify({
400
+ event: "typing",
401
+ data: { conversation_id: convId },
402
+ }));
403
+ }
404
+ }
405
+ /**
406
+ * Send an activity span to all owner devices via WS.
407
+ * Ephemeral (unencrypted metadata, like typing), no ratchet advancement.
408
+ */
409
+ sendActivitySpan(spanData) {
410
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN)
411
+ return;
412
+ this._ws.send(JSON.stringify({
413
+ event: "activity_span",
414
+ data: {
415
+ ...spanData,
416
+ agent_name: this.config.agentName ?? "Agent",
417
+ },
418
+ }));
419
+ }
420
+ /**
421
+ * Send a decision request to the owner.
422
+ * Builds a structured envelope with decision metadata and sends it
423
+ * as a high-priority message. Returns the generated decision_id.
424
+ */
425
+ async sendDecisionRequest(request) {
426
+ const decision_id = `dec_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
427
+ const payload = JSON.stringify({
428
+ type: "message",
429
+ text: `\u{1F4CB} ${request.title}`,
430
+ decision: {
431
+ decision_id,
432
+ ...request,
433
+ },
434
+ });
435
+ await this.send(payload, {
436
+ messageType: "decision_request",
437
+ priority: "high",
438
+ metadata: {
439
+ decision_id,
440
+ title: request.title,
441
+ description: request.description,
442
+ options: request.options,
443
+ context_refs: request.context_refs,
444
+ deadline: request.deadline,
445
+ auto_action: request.auto_action,
446
+ },
447
+ });
448
+ return decision_id;
449
+ }
450
+ /**
451
+ * Wait for a decision response matching the given decisionId.
452
+ * Listens on the "message" event for messages where
453
+ * metadata.messageType === "decision_response" and the parsed plaintext
454
+ * contains a matching decision.decision_id.
455
+ * Optional timeout rejects with an Error.
456
+ */
457
+ waitForDecision(decisionId, timeoutMs) {
458
+ return new Promise((resolve, reject) => {
459
+ let timer = null;
460
+ const handler = (plaintext, metadata) => {
461
+ if (metadata.messageType !== "decision_response")
462
+ return;
463
+ try {
464
+ const parsed = JSON.parse(plaintext);
465
+ if (parsed.decision?.decision_id === decisionId) {
466
+ if (timer)
467
+ clearTimeout(timer);
468
+ this.removeListener("message", handler);
469
+ resolve({
470
+ decision_id: parsed.decision.decision_id,
471
+ selected_option_id: parsed.decision.selected_option_id,
472
+ resolved_at: parsed.decision.resolved_at,
473
+ action: parsed.decision.action,
474
+ defer_until: parsed.decision.defer_until,
475
+ defer_reason: parsed.decision.defer_reason,
476
+ });
477
+ }
478
+ }
479
+ catch {
480
+ // Not valid JSON, ignore
481
+ }
482
+ };
483
+ this.on("message", handler);
484
+ if (timeoutMs !== undefined) {
485
+ timer = setTimeout(() => {
486
+ this.removeListener("message", handler);
487
+ reject(new Error(`Decision ${decisionId} timed out after ${timeoutMs}ms`));
488
+ }, timeoutMs);
489
+ }
490
+ });
491
+ }
492
+ // --- Multi-agent room methods ---
493
+ /**
494
+ * Join a room by performing X3DH key exchange with each member
495
+ * for the pairwise conversations involving this device.
496
+ */
497
+ async joinRoom(roomData) {
498
+ if (!this._persisted) {
499
+ throw new Error("Channel not initialized");
500
+ }
501
+ await sodium.ready;
502
+ // Force rekey: clear all existing sessions + sender key state for this room
503
+ if (roomData.forceRekey) {
504
+ let cleared = 0;
505
+ const existingRoom = this._persisted.rooms?.[roomData.roomId];
506
+ if (existingRoom) {
507
+ for (const convId of existingRoom.conversationIds) {
508
+ if (this._sessions.has(convId)) {
509
+ this._sessions.delete(convId);
510
+ cleared++;
511
+ }
512
+ delete this._persisted.sessions[convId];
513
+ }
514
+ }
515
+ // Clear sender key chains/state for this room
516
+ this._senderKeyChains.delete(roomData.roomId);
517
+ this._senderKeyStates.delete(roomData.roomId);
518
+ if (existingRoom) {
519
+ delete existingRoom.ownSenderKeyChain;
520
+ delete existingRoom.peerSenderKeyState;
521
+ delete existingRoom.encryptionMode;
522
+ existingRoom.distributedTo = [];
523
+ }
524
+ // Advance sync timestamp to now so _syncMissedMessages skips old
525
+ // ciphertext that would corrupt the freshly initialized ratchets
526
+ if (this._persisted) {
527
+ this._persisted.lastMessageTimestamp = new Date().toISOString();
528
+ }
529
+ console.log(`[SecureChannel] Force rekey: cleared ${cleared} sessions for room ${roomData.roomId.slice(0, 8)}...`);
530
+ }
531
+ const identity = this._persisted.identityKeypair;
532
+ const ephemeral = this._persisted.ephemeralKeypair;
533
+ const myDeviceId = this._deviceId;
534
+ const conversationIds = [];
535
+ for (const conv of roomData.conversations) {
536
+ // Only process conversations involving this device
537
+ if (conv.participantA !== myDeviceId && conv.participantB !== myDeviceId) {
538
+ continue;
539
+ }
540
+ // Skip conversations that already have active sessions (idempotent re-join)
541
+ if (this._sessions.has(conv.id)) {
542
+ conversationIds.push(conv.id);
543
+ continue;
544
+ }
545
+ const otherDeviceId = conv.participantA === myDeviceId ? conv.participantB : conv.participantA;
546
+ const otherMember = roomData.members.find((m) => m.deviceId === otherDeviceId);
547
+ if (!otherMember?.identityPublicKey) {
548
+ console.warn(`[SecureChannel] No public key for member ${otherDeviceId.slice(0, 8)}..., skipping`);
549
+ continue;
550
+ }
551
+ // Determine initiator: lexicographically smaller deviceId is the sender (initiator)
552
+ const isInitiator = myDeviceId < otherDeviceId;
553
+ const theirEphKey = otherMember.ephemeralPublicKey ?? otherMember.identityPublicKey;
554
+ const sharedSecret = performX3DH({
555
+ myIdentityPrivate: hexToBytes(identity.privateKey),
556
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
557
+ theirIdentityPublic: hexToBytes(otherMember.identityPublicKey),
558
+ theirEphemeralPublic: hexToBytes(theirEphKey),
559
+ isInitiator,
560
+ });
561
+ const peerIdentityPub = hexToBytes(otherMember.identityPublicKey);
562
+ const ratchet = isInitiator
563
+ ? DoubleRatchet.initSender(sharedSecret, {
564
+ publicKey: hexToBytes(identity.publicKey),
565
+ privateKey: hexToBytes(identity.privateKey),
566
+ keyType: "ed25519",
567
+ }, peerIdentityPub)
568
+ : DoubleRatchet.initReceiver(sharedSecret, {
569
+ publicKey: hexToBytes(identity.publicKey),
570
+ privateKey: hexToBytes(identity.privateKey),
571
+ keyType: "ed25519",
572
+ }, peerIdentityPub);
573
+ this._sessions.set(conv.id, {
574
+ ownerDeviceId: otherDeviceId,
575
+ ratchet,
576
+ activated: isInitiator, // initiator can send immediately
577
+ });
578
+ // Persist session
579
+ this._persisted.sessions[conv.id] = {
580
+ ownerDeviceId: otherDeviceId,
581
+ ratchetState: ratchet.serialize(),
582
+ activated: isInitiator,
583
+ };
584
+ conversationIds.push(conv.id);
585
+ console.log(`[SecureChannel] Room session initialized: conv ${conv.id.slice(0, 8)}... ` +
586
+ `with ${otherDeviceId.slice(0, 8)}... (initiator=${isInitiator})`);
587
+ }
588
+ // Store room state
589
+ if (!this._persisted.rooms) {
590
+ this._persisted.rooms = {};
591
+ }
592
+ this._persisted.rooms[roomData.roomId] = {
593
+ roomId: roomData.roomId,
594
+ name: roomData.name,
595
+ conversationIds,
596
+ members: roomData.members,
597
+ };
598
+ // ── Sender Key setup ──
599
+ // Create own chain if not already present
600
+ if (!this._senderKeyChains.has(roomData.roomId)) {
601
+ const chain = SenderKeyChain.create();
602
+ this._senderKeyChains.set(roomData.roomId, chain);
603
+ this._persisted.rooms[roomData.roomId].ownSenderKeyChain = chain.serialize();
604
+ }
605
+ if (!this._senderKeyStates.has(roomData.roomId)) {
606
+ const peerState = SenderKeyState.create();
607
+ this._senderKeyStates.set(roomData.roomId, peerState);
608
+ this._persisted.rooms[roomData.roomId].peerSenderKeyState = peerState.serialize();
609
+ }
610
+ await this._persistState();
611
+ this.emit("room_joined", { roomId: roomData.roomId, name: roomData.name });
612
+ // Distribute sender key to all members (fire-and-forget)
613
+ // Skip during force rekey — the owner hasn't re-mounted yet and can't
614
+ // decrypt these. Wait for the owner to distribute first, then reciprocate.
615
+ if (!roomData.forceRekey) {
616
+ this._distributeSenderKey(roomData.roomId).catch((err) => {
617
+ console.warn(`[SecureChannel] Sender key distribution failed for room ${roomData.roomId}:`, err);
618
+ });
619
+ }
620
+ }
621
+ /**
622
+ * Send an encrypted message to all members of a room.
623
+ * Uses Sender Key (O(1)) if available, otherwise pairwise fan-out.
624
+ */
625
+ async sendToRoom(roomId, plaintext, opts) {
626
+ if (!this._persisted?.rooms?.[roomId]) {
627
+ throw new Error(`Room ${roomId} not found`);
628
+ }
629
+ // Wake agent proactively before sending (fire-and-forget, best-effort)
630
+ import("./openclaw-compat.js")
631
+ .then(({ requestHeartbeatNow }) => requestHeartbeatNow({ reason: "channel-sendToRoom" }))
632
+ .catch(() => { });
633
+ const room = this._persisted.rooms[roomId];
634
+ const messageType = opts?.messageType ?? "text";
635
+ // ── Sender Key path (O(1) encryption) ──
636
+ const chain = this._senderKeyChains.get(roomId);
637
+ if (room.encryptionMode === "sender_key" && chain) {
638
+ const skMsg = chain.encrypt(plaintext);
639
+ // Persist updated chain state
640
+ room.ownSenderKeyChain = chain.serialize();
641
+ await this._persistState();
642
+ if (this._state === "ready" && this._ws) {
643
+ this._ws.send(JSON.stringify({
644
+ event: "room_message_sk",
645
+ data: {
646
+ room_id: roomId,
647
+ iteration: skMsg.iteration,
648
+ generation_id: skMsg.generationId,
649
+ nonce: skMsg.nonce,
650
+ ciphertext: skMsg.ciphertext,
651
+ signature: skMsg.signature,
652
+ message_type: messageType,
653
+ priority: opts?.priority ?? "normal",
654
+ metadata: opts?.metadata,
655
+ },
656
+ }));
657
+ }
658
+ return;
659
+ }
660
+ // ── Pairwise fallback ──
661
+ const recipients = [];
662
+ for (const convId of room.conversationIds) {
663
+ const session = this._sessions.get(convId);
664
+ if (!session) {
665
+ console.warn(`[SecureChannel] No session for room conv ${convId.slice(0, 8)}..., skipping`);
666
+ continue;
667
+ }
668
+ const encrypted = session.ratchet.encrypt(plaintext);
669
+ const transport = encryptedMessageToTransport(encrypted);
670
+ recipients.push({
671
+ device_id: session.ownerDeviceId,
672
+ header_blob: transport.header_blob,
673
+ ciphertext: transport.ciphertext,
674
+ });
675
+ }
676
+ if (recipients.length === 0) {
677
+ throw new Error("No active sessions in room");
678
+ }
679
+ if (this._state === "ready" && this._ws) {
680
+ this._ws.send(JSON.stringify({
681
+ event: "room_message",
682
+ data: {
683
+ room_id: roomId,
684
+ recipients,
685
+ message_type: messageType,
686
+ priority: opts?.priority ?? "normal",
687
+ metadata: opts?.metadata,
688
+ },
689
+ }));
690
+ }
691
+ else {
692
+ // HTTP fallback
693
+ try {
694
+ const res = await fetch(`${this.config.apiUrl}/api/v1/rooms/${roomId}/messages`, {
695
+ method: "POST",
696
+ headers: {
697
+ "Content-Type": "application/json",
698
+ Authorization: `Bearer ${this._deviceJwt}`,
699
+ },
700
+ body: JSON.stringify({
701
+ recipients,
702
+ message_type: messageType,
703
+ priority: opts?.priority ?? "normal",
704
+ metadata: opts?.metadata,
705
+ }),
706
+ });
707
+ if (!res.ok) {
708
+ const detail = await res.text();
709
+ throw new Error(`Room message failed (${res.status}): ${detail}`);
710
+ }
711
+ }
712
+ catch (err) {
713
+ throw new Error(`Failed to send room message: ${err}`);
714
+ }
715
+ }
716
+ await this._persistState();
717
+ }
718
+ /**
719
+ * Leave a room: remove sessions and persisted room state.
720
+ */
721
+ async leaveRoom(roomId) {
722
+ if (!this._persisted?.rooms?.[roomId]) {
723
+ return; // Already left or never joined
724
+ }
725
+ const room = this._persisted.rooms[roomId];
726
+ // Remove sessions for this room's conversations
727
+ for (const convId of room.conversationIds) {
728
+ this._sessions.delete(convId);
729
+ delete this._persisted.sessions[convId];
730
+ }
731
+ // Remove room state
732
+ delete this._persisted.rooms[roomId];
733
+ await this._persistState();
734
+ this.emit("room_left", { roomId });
735
+ }
736
+ /**
737
+ * Return info for all joined rooms.
738
+ */
739
+ getRooms() {
740
+ if (!this._persisted?.rooms)
741
+ return [];
742
+ return Object.values(this._persisted.rooms).map((rs) => ({
743
+ roomId: rs.roomId,
744
+ name: rs.name,
745
+ members: rs.members,
746
+ conversationIds: rs.conversationIds,
747
+ }));
748
+ }
749
+ // --- Heartbeat and status methods ---
750
+ startHeartbeat(intervalSeconds, statusCallback) {
751
+ this.stopHeartbeat();
752
+ this._heartbeatCallback = statusCallback;
753
+ this._heartbeatIntervalSeconds = intervalSeconds;
754
+ this._sendHeartbeat();
755
+ this._heartbeatTimer = setInterval(() => {
756
+ this._sendHeartbeat();
757
+ }, intervalSeconds * 1000);
758
+ }
759
+ async stopHeartbeat() {
760
+ if (this._heartbeatTimer) {
761
+ clearInterval(this._heartbeatTimer);
762
+ this._heartbeatTimer = null;
763
+ }
764
+ if (this._heartbeatCallback && this._state === "ready") {
765
+ try {
766
+ await this.send(JSON.stringify({
767
+ agent_status: "shutting_down",
768
+ current_task: "",
769
+ timestamp: new Date().toISOString(),
770
+ }), {
771
+ messageType: "heartbeat",
772
+ metadata: { next_heartbeat_seconds: this._heartbeatIntervalSeconds },
773
+ });
774
+ }
775
+ catch { /* best-effort shutdown heartbeat */ }
776
+ }
777
+ this._heartbeatCallback = null;
778
+ }
779
+ async sendStatusAlert(alert) {
780
+ const priority = alert.severity === "error" || alert.severity === "critical"
781
+ ? "high" : "normal";
782
+ const envelope = {
783
+ title: alert.title,
784
+ message: alert.message,
785
+ severity: alert.severity,
786
+ timestamp: new Date().toISOString(),
787
+ };
788
+ if (alert.detail !== undefined)
789
+ envelope.detail = alert.detail;
790
+ if (alert.detailFormat !== undefined)
791
+ envelope.detail_format = alert.detailFormat;
792
+ if (alert.category !== undefined)
793
+ envelope.category = alert.category;
794
+ await this.send(JSON.stringify(envelope), {
795
+ messageType: "status_alert",
796
+ priority,
797
+ metadata: { severity: alert.severity },
798
+ });
799
+ }
800
+ async sendArtifact(artifact) {
801
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
802
+ throw new Error("Channel is not ready");
803
+ }
804
+ // Upload encrypted file via existing attachment infrastructure
805
+ const attachMeta = await this._uploadAttachment(artifact.filePath, this._primaryConversationId);
806
+ // Build artifact_share envelope
807
+ const envelope = JSON.stringify({
808
+ type: "artifact",
809
+ blob_id: attachMeta.blobId,
810
+ blob_url: attachMeta.blobUrl,
811
+ filename: artifact.filename,
812
+ mime_type: artifact.mimeType,
813
+ size_bytes: attachMeta.size,
814
+ description: artifact.description,
815
+ attachment: attachMeta,
816
+ });
817
+ const messageGroupId = randomUUID();
818
+ for (const [convId, session] of this._sessions) {
819
+ if (!session.activated)
820
+ continue;
821
+ const encrypted = session.ratchet.encrypt(envelope);
822
+ const transport = encryptedMessageToTransport(encrypted);
823
+ this._ws.send(JSON.stringify({
824
+ event: "message",
825
+ data: {
826
+ conversation_id: convId,
827
+ header_blob: transport.header_blob,
828
+ ciphertext: transport.ciphertext,
829
+ message_group_id: messageGroupId,
830
+ message_type: "artifact_share",
831
+ },
832
+ }));
833
+ }
834
+ await this._persistState();
835
+ }
836
+ async sendActionConfirmation(confirmation) {
837
+ const envelope = {
838
+ type: "action_confirmation",
839
+ action: confirmation.action,
840
+ status: confirmation.status,
841
+ };
842
+ if (confirmation.decisionId !== undefined)
843
+ envelope.decision_id = confirmation.decisionId;
844
+ if (confirmation.detail !== undefined)
845
+ envelope.detail = confirmation.detail;
846
+ await this.send(JSON.stringify(envelope), {
847
+ messageType: "action_confirmation",
848
+ metadata: { status: confirmation.status },
849
+ });
850
+ }
851
+ async sendActionConfirmationToRoom(roomId, confirmation) {
852
+ const envelope = {
853
+ type: "action_confirmation",
854
+ action: confirmation.action,
855
+ status: confirmation.status,
856
+ };
857
+ if (confirmation.decisionId !== undefined)
858
+ envelope.decision_id = confirmation.decisionId;
859
+ if (confirmation.detail !== undefined)
860
+ envelope.detail = confirmation.detail;
861
+ const metadata = {
862
+ action: confirmation.action,
863
+ status: confirmation.status,
864
+ };
865
+ if (confirmation.estimated_cost !== undefined) {
866
+ metadata.estimated_cost = confirmation.estimated_cost;
867
+ }
868
+ await this.sendToRoom(roomId, JSON.stringify(envelope), {
869
+ messageType: "action_confirmation",
870
+ priority: "high",
871
+ metadata,
872
+ });
873
+ }
874
+ _sendHeartbeat() {
875
+ if (this._state !== "ready" || !this._heartbeatCallback)
876
+ return;
877
+ const status = this._heartbeatCallback();
878
+ this.send(JSON.stringify({
879
+ agent_status: status.agent_status,
880
+ current_task: status.current_task,
881
+ timestamp: new Date().toISOString(),
882
+ }), {
883
+ messageType: "heartbeat",
884
+ metadata: { next_heartbeat_seconds: this._heartbeatIntervalSeconds },
885
+ }).catch((err) => {
886
+ this.emit("error", new Error(`Heartbeat send failed: ${err}`));
887
+ });
888
+ }
889
+ async stop() {
890
+ this._stopped = true;
891
+ await this.stopHeartbeat();
892
+ this._flushAcks(); // Send any pending ACKs before stopping
893
+ this._stopPing();
894
+ this._stopWakeDetector();
895
+ this._stopPendingPoll();
896
+ this._stopPollFallback();
897
+ this._stopHttpServer();
898
+ if (this._ackTimer) {
899
+ clearTimeout(this._ackTimer);
900
+ this._ackTimer = null;
901
+ }
902
+ if (this._pollTimer) {
903
+ clearTimeout(this._pollTimer);
904
+ this._pollTimer = null;
905
+ }
906
+ if (this._reconnectTimer) {
907
+ clearTimeout(this._reconnectTimer);
908
+ this._reconnectTimer = null;
909
+ }
910
+ // Flush and clean up telemetry reporter before closing WebSocket
911
+ if (this._telemetryReporter) {
912
+ this._telemetryReporter.stopAutoFlush();
913
+ await this._telemetryReporter.flush();
914
+ this._telemetryReporter = null;
915
+ }
916
+ if (this._ws) {
917
+ this._ws.removeAllListeners();
918
+ this._ws.close();
919
+ this._ws = null;
920
+ }
921
+ this._setState("disconnected");
922
+ }
923
+ // --- Local HTTP server for proactive sends ---
924
+ startHttpServer(port) {
925
+ if (this._httpServer)
926
+ return;
927
+ // Lazy import to avoid circular dependency at module load time
928
+ let _handlers = null;
929
+ const getHandlers = async () => {
930
+ if (!_handlers)
931
+ _handlers = await import("./http-handlers.js");
932
+ return _handlers;
933
+ };
934
+ this._httpServer = createServer(async (req, res) => {
935
+ // Only accept local connections
936
+ const remote = req.socket.remoteAddress;
937
+ if (remote !== "127.0.0.1" && remote !== "::1" && remote !== "::ffff:127.0.0.1") {
938
+ res.writeHead(403, { "Content-Type": "application/json" });
939
+ res.end(JSON.stringify({ ok: false, error: "Forbidden" }));
940
+ return;
941
+ }
942
+ const handlers = await getHandlers();
943
+ if (req.method === "POST" && req.url === "/send") {
944
+ let body = "";
945
+ req.on("data", (chunk) => { body += chunk.toString(); });
946
+ req.on("end", async () => {
947
+ try {
948
+ const parsed = JSON.parse(body);
949
+ const result = await handlers.handleSendRequest(parsed, this);
950
+ res.writeHead(result.status, { "Content-Type": "application/json" });
951
+ res.end(JSON.stringify(result.body));
952
+ }
953
+ catch (err) {
954
+ res.writeHead(500, { "Content-Type": "application/json" });
955
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
956
+ }
957
+ });
958
+ }
959
+ else if (req.method === "POST" && req.url === "/decision") {
960
+ let body = "";
961
+ req.on("data", (chunk) => { body += chunk.toString(); });
962
+ req.on("end", async () => {
963
+ try {
964
+ const parsed = JSON.parse(body);
965
+ const result = await handlers.handleDecisionRequest(parsed, this);
966
+ res.writeHead(result.status, { "Content-Type": "application/json" });
967
+ res.end(JSON.stringify(result.body));
968
+ }
969
+ catch (err) {
970
+ res.writeHead(500, { "Content-Type": "application/json" });
971
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
972
+ }
973
+ });
974
+ }
975
+ else if (req.method === "POST" && req.url === "/action") {
976
+ let body = "";
977
+ req.on("data", (chunk) => { body += chunk.toString(); });
978
+ req.on("end", async () => {
979
+ try {
980
+ const parsed = JSON.parse(body);
981
+ const result = await handlers.handleActionRequest(parsed, this);
982
+ res.writeHead(result.status, { "Content-Type": "application/json" });
983
+ res.end(JSON.stringify(result.body));
984
+ }
985
+ catch (err) {
986
+ res.writeHead(500, { "Content-Type": "application/json" });
987
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
988
+ }
989
+ });
990
+ }
991
+ else if (req.method === "GET" && req.url === "/status") {
992
+ const result = handlers.handleStatusRequest(this);
993
+ res.writeHead(result.status, { "Content-Type": "application/json" });
994
+ res.end(JSON.stringify(result.body));
995
+ }
996
+ else {
997
+ res.writeHead(404, { "Content-Type": "application/json" });
998
+ res.end(JSON.stringify({ ok: false, error: "Not found. Use POST /send, POST /decision, POST /action, or GET /status" }));
999
+ }
1000
+ });
1001
+ this._httpServer.listen(port, "127.0.0.1", () => {
1002
+ this.emit("http-ready", port);
1003
+ });
1004
+ }
1005
+ _stopHttpServer() {
1006
+ if (this._httpServer) {
1007
+ this._httpServer.close();
1008
+ this._httpServer = null;
1009
+ }
1010
+ }
1011
+ // --- Topic management ---
1012
+ /**
1013
+ * Create a new topic within the conversation group.
1014
+ * Requires the channel to be initialized with a groupId (from activation).
1015
+ */
1016
+ async createTopic(name) {
1017
+ if (!this._persisted?.groupId) {
1018
+ throw new Error("Channel not initialized or groupId unknown");
1019
+ }
1020
+ if (!this._deviceJwt) {
1021
+ throw new Error("Channel not authenticated");
1022
+ }
1023
+ const res = await fetch(`${this.config.apiUrl}/api/v1/topics`, {
1024
+ method: "POST",
1025
+ headers: {
1026
+ "Content-Type": "application/json",
1027
+ Authorization: `Bearer ${this._deviceJwt}`,
1028
+ },
1029
+ body: JSON.stringify({
1030
+ group_id: this._persisted.groupId,
1031
+ name,
1032
+ creator_device_id: this._persisted.deviceId,
1033
+ }),
1034
+ });
1035
+ if (!res.ok) {
1036
+ const detail = await res.text();
1037
+ throw new Error(`Create topic failed (${res.status}): ${detail}`);
1038
+ }
1039
+ const resp = await res.json();
1040
+ const topic = { id: resp.id, name: resp.name, isDefault: resp.is_default };
1041
+ if (!this._persisted.topics) {
1042
+ this._persisted.topics = [];
1043
+ }
1044
+ this._persisted.topics.push(topic);
1045
+ await this._persistState();
1046
+ return topic;
1047
+ }
1048
+ /**
1049
+ * List all topics in the conversation group.
1050
+ * Requires the channel to be initialized with a groupId (from activation).
1051
+ */
1052
+ async listTopics() {
1053
+ if (!this._persisted?.groupId) {
1054
+ throw new Error("Channel not initialized or groupId unknown");
1055
+ }
1056
+ if (!this._deviceJwt) {
1057
+ throw new Error("Channel not authenticated");
1058
+ }
1059
+ const res = await fetch(`${this.config.apiUrl}/api/v1/topics?group_id=${encodeURIComponent(this._persisted.groupId)}`, {
1060
+ headers: {
1061
+ Authorization: `Bearer ${this._deviceJwt}`,
1062
+ },
1063
+ });
1064
+ if (!res.ok) {
1065
+ const detail = await res.text();
1066
+ throw new Error(`List topics failed (${res.status}): ${detail}`);
1067
+ }
1068
+ const resp = await res.json();
1069
+ const topics = resp.map((t) => ({ id: t.id, name: t.name, isDefault: t.is_default }));
1070
+ this._persisted.topics = topics;
1071
+ await this._persistState();
1072
+ return topics;
1073
+ }
1074
+ // --- A2A Channel methods (agent-to-agent communication) ---
1075
+ /**
1076
+ * Request a new A2A channel with another agent by their hub address.
1077
+ * Returns the channel_id from the server response.
1078
+ */
1079
+ async requestA2AChannel(responderHubAddress) {
1080
+ if (!this._persisted?.hubAddress) {
1081
+ throw new Error("This agent does not have a hub address assigned");
1082
+ }
1083
+ if (!this._deviceJwt) {
1084
+ throw new Error("Channel not authenticated");
1085
+ }
1086
+ const res = await fetch(`${this.config.apiUrl}/api/v1/a2a/channels/request`, {
1087
+ method: "POST",
1088
+ headers: {
1089
+ "Content-Type": "application/json",
1090
+ Authorization: `Bearer ${this._deviceJwt}`,
1091
+ },
1092
+ body: JSON.stringify({
1093
+ initiator_hub_address: this._persisted.hubAddress,
1094
+ responder_hub_address: responderHubAddress,
1095
+ }),
1096
+ });
1097
+ if (!res.ok) {
1098
+ const detail = await res.text();
1099
+ throw new Error(`A2A channel request failed (${res.status}): ${detail}`);
1100
+ }
1101
+ const resp = await res.json();
1102
+ const channelId = resp.channel_id || resp.id;
1103
+ // Store channel info in persisted state
1104
+ if (!this._persisted.a2aChannels) {
1105
+ this._persisted.a2aChannels = {};
1106
+ }
1107
+ this._persisted.a2aChannels[channelId] = {
1108
+ channelId,
1109
+ hubAddress: responderHubAddress,
1110
+ conversationId: resp.conversation_id || "",
1111
+ };
1112
+ await this._persistState();
1113
+ return channelId;
1114
+ }
1115
+ /**
1116
+ * Send a message to another agent via an active A2A channel.
1117
+ * Looks up the A2A conversation by hub address and sends via WS.
1118
+ *
1119
+ * If the channel has an established E2E session, the message is encrypted
1120
+ * with the Double Ratchet. If the responder hasn't received the initiator's
1121
+ * first message yet (ratchet not activated), the message is queued locally
1122
+ * and flushed when the first inbound message arrives.
1123
+ *
1124
+ * Falls back to plaintext for channels without a session (legacy/pre-encryption).
1125
+ */
1126
+ async sendToAgent(hubAddress, text, opts) {
1127
+ if (!this._persisted?.a2aChannels) {
1128
+ throw new Error("No A2A channels established");
1129
+ }
1130
+ // Find the active channel for this hub address
1131
+ const channelEntry = Object.values(this._persisted.a2aChannels).find((ch) => ch.hubAddress === hubAddress);
1132
+ if (!channelEntry) {
1133
+ throw new Error(`No A2A channel found for hub address: ${hubAddress}`);
1134
+ }
1135
+ if (!channelEntry.conversationId) {
1136
+ throw new Error(`A2A channel ${channelEntry.channelId} does not have a conversation yet (not activated)`);
1137
+ }
1138
+ if (this._state !== "ready" || !this._ws) {
1139
+ throw new Error("Channel is not connected");
1140
+ }
1141
+ // --- E2E encrypted path ---
1142
+ if (channelEntry.session?.ratchetState) {
1143
+ // Responder must wait for first initiator message before sending
1144
+ if (channelEntry.role === "responder" && !channelEntry.session.activated) {
1145
+ if (!this._a2aPendingQueue[channelEntry.channelId]) {
1146
+ this._a2aPendingQueue[channelEntry.channelId] = [];
1147
+ }
1148
+ this._a2aPendingQueue[channelEntry.channelId].push({ text, opts });
1149
+ console.log(`[SecureChannel] A2A message queued (responder not yet activated, ` +
1150
+ `queue=${this._a2aPendingQueue[channelEntry.channelId].length})`);
1151
+ return;
1152
+ }
1153
+ // Deserialize ratchet, encrypt, update state
1154
+ const ratchet = DoubleRatchet.deserialize(channelEntry.session.ratchetState);
1155
+ const encrypted = ratchet.encrypt(text);
1156
+ // Serialize header for transport (hex-encoded JSON)
1157
+ const headerObj = {
1158
+ dhPublicKey: bytesToHex(encrypted.header.dhPublicKey),
1159
+ previousChainLength: encrypted.header.previousChainLength,
1160
+ messageNumber: encrypted.header.messageNumber,
1161
+ };
1162
+ const headerBlobHex = Buffer.from(JSON.stringify(headerObj)).toString("hex");
1163
+ const payload = {
1164
+ channel_id: channelEntry.channelId,
1165
+ conversation_id: channelEntry.conversationId,
1166
+ header_blob: headerBlobHex,
1167
+ header_signature: bytesToHex(encrypted.headerSignature),
1168
+ ciphertext: bytesToHex(encrypted.ciphertext),
1169
+ nonce: bytesToHex(encrypted.nonce),
1170
+ parent_span_id: opts?.parentSpanId,
1171
+ };
1172
+ if (this._persisted.hubAddress) {
1173
+ payload.hub_address = this._persisted.hubAddress;
1174
+ }
1175
+ // Observer copy (if observer session exists)
1176
+ if (channelEntry.observerSession?.ratchetState) {
1177
+ try {
1178
+ const obsRatchet = DoubleRatchet.deserialize(channelEntry.observerSession.ratchetState);
1179
+ const obsEncrypted = obsRatchet.encrypt(text);
1180
+ const obsHeaderObj = {
1181
+ dhPublicKey: bytesToHex(obsEncrypted.header.dhPublicKey),
1182
+ previousChainLength: obsEncrypted.header.previousChainLength,
1183
+ messageNumber: obsEncrypted.header.messageNumber,
1184
+ };
1185
+ payload.observer_header_blob = Buffer.from(JSON.stringify(obsHeaderObj)).toString("hex");
1186
+ payload.observer_ciphertext = bytesToHex(obsEncrypted.ciphertext);
1187
+ payload.observer_nonce = bytesToHex(obsEncrypted.nonce);
1188
+ channelEntry.observerSession.ratchetState = obsRatchet.serialize();
1189
+ }
1190
+ catch (obsErr) {
1191
+ console.error("[SecureChannel] Observer encryption failed (sending without observer copy):", obsErr);
1192
+ }
1193
+ }
1194
+ // Update persisted ratchet state
1195
+ channelEntry.session.ratchetState = ratchet.serialize();
1196
+ await this._persistState();
1197
+ this._ws.send(JSON.stringify({
1198
+ event: "a2a_message",
1199
+ data: payload,
1200
+ }));
1201
+ return;
1202
+ }
1203
+ // --- Legacy plaintext fallback (no session) ---
1204
+ const payload = {
1205
+ conversation_id: channelEntry.conversationId,
1206
+ channel_id: channelEntry.channelId,
1207
+ text: text,
1208
+ message_type: "a2a",
1209
+ parent_span_id: opts?.parentSpanId,
1210
+ };
1211
+ if (this._persisted.hubAddress) {
1212
+ payload.hub_address = this._persisted.hubAddress;
1213
+ }
1214
+ this._ws.send(JSON.stringify({
1215
+ event: "a2a_message",
1216
+ data: payload,
1217
+ }));
1218
+ }
1219
+ /**
1220
+ * List all A2A channels for this agent.
1221
+ * Fetches from the server and updates local persisted state.
1222
+ */
1223
+ async listA2AChannels() {
1224
+ if (!this._deviceJwt) {
1225
+ throw new Error("Channel not authenticated");
1226
+ }
1227
+ const res = await fetch(`${this.config.apiUrl}/api/v1/a2a/channels`, {
1228
+ headers: {
1229
+ Authorization: `Bearer ${this._deviceJwt}`,
1230
+ },
1231
+ });
1232
+ if (!res.ok) {
1233
+ const detail = await res.text();
1234
+ throw new Error(`List A2A channels failed (${res.status}): ${detail}`);
1235
+ }
1236
+ const resp = await res.json();
1237
+ const channels = resp.map((ch) => ({
1238
+ channelId: ch.channel_id || ch.id,
1239
+ initiatorHubAddress: ch.initiator_hub_address,
1240
+ responderHubAddress: ch.responder_hub_address,
1241
+ conversationId: ch.conversation_id,
1242
+ status: ch.status,
1243
+ createdAt: ch.created_at,
1244
+ }));
1245
+ // Update persisted a2aChannels map with latest server data
1246
+ if (this._persisted) {
1247
+ if (!this._persisted.a2aChannels) {
1248
+ this._persisted.a2aChannels = {};
1249
+ }
1250
+ for (const ch of channels) {
1251
+ if (ch.status === "active" || ch.status === "approved") {
1252
+ const otherAddress = ch.initiatorHubAddress === this._persisted.hubAddress
1253
+ ? ch.responderHubAddress
1254
+ : ch.initiatorHubAddress;
1255
+ this._persisted.a2aChannels[ch.channelId] = {
1256
+ channelId: ch.channelId,
1257
+ hubAddress: otherAddress,
1258
+ conversationId: ch.conversationId || "",
1259
+ };
1260
+ }
1261
+ }
1262
+ await this._persistState();
1263
+ }
1264
+ return channels;
1265
+ }
1266
+ // --- Internal lifecycle ---
1267
+ async _enroll() {
1268
+ this._setState("enrolling");
1269
+ try {
1270
+ const identity = await generateIdentityKeypair();
1271
+ const ephemeral = await generateEphemeralKeypair();
1272
+ const fingerprint = computeFingerprint(identity.publicKey);
1273
+ const proof = createProofOfPossession(identity.privateKey, identity.publicKey);
1274
+ const result = await enrollDevice(this.config.apiUrl, this.config.inviteToken, bytesToHex(identity.publicKey), bytesToHex(ephemeral.publicKey), bytesToHex(proof), this.config.platform);
1275
+ this._deviceId = result.device_id;
1276
+ this._fingerprint = result.fingerprint;
1277
+ // Store keypairs temporarily for activation (not persisted yet -- no JWT)
1278
+ this._persisted = {
1279
+ deviceId: result.device_id,
1280
+ deviceJwt: "", // set after activation
1281
+ primaryConversationId: "", // set after activation
1282
+ sessions: {}, // populated after activation
1283
+ identityKeypair: {
1284
+ publicKey: bytesToHex(identity.publicKey),
1285
+ privateKey: bytesToHex(identity.privateKey),
1286
+ },
1287
+ ephemeralKeypair: {
1288
+ publicKey: bytesToHex(ephemeral.publicKey),
1289
+ privateKey: bytesToHex(ephemeral.privateKey),
1290
+ },
1291
+ fingerprint: result.fingerprint,
1292
+ messageHistory: [],
1293
+ };
1294
+ this._poll();
1295
+ }
1296
+ catch (err) {
1297
+ this._handleError(err);
1298
+ }
1299
+ }
1300
+ _poll() {
1301
+ if (this._stopped)
1302
+ return;
1303
+ this._setState("polling");
1304
+ const doPoll = async () => {
1305
+ if (this._stopped)
1306
+ return;
1307
+ try {
1308
+ const status = await pollDeviceStatus(this.config.apiUrl, this._deviceId);
1309
+ if (status.rateLimited) {
1310
+ // Server rate-limited this poll — back off 2x before retrying
1311
+ this._pollTimer = setTimeout(doPoll, POLL_INTERVAL_MS * 2);
1312
+ return;
1313
+ }
1314
+ if (status.status === "APPROVED") {
1315
+ await this._activate();
1316
+ return;
1317
+ }
1318
+ if (status.status === "REVOKED") {
1319
+ this._handleError(new Error("Device was revoked"));
1320
+ return;
1321
+ }
1322
+ // Still PENDING -- poll again
1323
+ this._pollTimer = setTimeout(doPoll, POLL_INTERVAL_MS);
1324
+ }
1325
+ catch (err) {
1326
+ this._handleError(err);
1327
+ }
1328
+ };
1329
+ this._pollTimer = setTimeout(doPoll, POLL_INTERVAL_MS);
1330
+ }
1331
+ async _activate() {
1332
+ this._setState("activating");
1333
+ try {
1334
+ const result = await activateDevice(this.config.apiUrl, this._deviceId);
1335
+ // Support both old (single conversation_id) and new (conversations array) response
1336
+ const conversations = result.conversations || [
1337
+ {
1338
+ conversation_id: result.conversation_id,
1339
+ owner_device_id: "",
1340
+ is_primary: true,
1341
+ },
1342
+ ];
1343
+ const primary = conversations.find((c) => c.is_primary) || conversations[0];
1344
+ this._primaryConversationId = primary.conversation_id;
1345
+ this._deviceJwt = result.device_jwt;
1346
+ const identity = this._persisted.identityKeypair;
1347
+ const ephemeral = this._persisted.ephemeralKeypair;
1348
+ const sessions = {};
1349
+ // Initialize X3DH + ratchet for EVERY conversation (not just primary)
1350
+ for (const conv of conversations) {
1351
+ // Use per-conversation owner keys if available, fallback to primary keys
1352
+ const ownerIdentityKey = conv.owner_identity_public_key || result.owner_identity_public_key;
1353
+ const ownerEphemeralKey = conv.owner_ephemeral_public_key ||
1354
+ result.owner_ephemeral_public_key ||
1355
+ ownerIdentityKey;
1356
+ const sharedSecret = performX3DH({
1357
+ myIdentityPrivate: hexToBytes(identity.privateKey),
1358
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
1359
+ theirIdentityPublic: hexToBytes(ownerIdentityKey),
1360
+ theirEphemeralPublic: hexToBytes(ownerEphemeralKey),
1361
+ isInitiator: false,
1362
+ });
1363
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
1364
+ publicKey: hexToBytes(identity.publicKey),
1365
+ privateKey: hexToBytes(identity.privateKey),
1366
+ keyType: "ed25519",
1367
+ }, hexToBytes(ownerIdentityKey));
1368
+ this._sessions.set(conv.conversation_id, {
1369
+ ownerDeviceId: conv.owner_device_id,
1370
+ ratchet,
1371
+ activated: false, // Wait for owner's first message before sending to this session
1372
+ });
1373
+ sessions[conv.conversation_id] = {
1374
+ ownerDeviceId: conv.owner_device_id,
1375
+ ratchetState: ratchet.serialize(),
1376
+ };
1377
+ console.log(`[SecureChannel] Session initialized for conv ${conv.conversation_id.slice(0, 8)}... (owner ${conv.owner_device_id.slice(0, 8)}..., primary=${conv.is_primary})`);
1378
+ }
1379
+ // Persist full state (preserve existing messageHistory)
1380
+ this._persisted = {
1381
+ ...this._persisted,
1382
+ deviceJwt: result.device_jwt,
1383
+ primaryConversationId: primary.conversation_id,
1384
+ sessions,
1385
+ messageHistory: this._persisted.messageHistory ?? [],
1386
+ };
1387
+ // Store group_id and default_topic_id for topic API calls
1388
+ if (conversations.length > 0) {
1389
+ const firstConv = conversations[0];
1390
+ if (firstConv.group_id) {
1391
+ this._persisted.groupId = firstConv.group_id;
1392
+ }
1393
+ if (firstConv.default_topic_id) {
1394
+ this._persisted.defaultTopicId = firstConv.default_topic_id;
1395
+ }
1396
+ }
1397
+ await saveState(this.config.dataDir, this._persisted);
1398
+ // Register webhook if configured
1399
+ if (this.config.webhookUrl) {
1400
+ try {
1401
+ const webhookResp = await fetch(`${this.config.apiUrl}/api/v1/devices/self/webhook`, {
1402
+ method: "PATCH",
1403
+ headers: {
1404
+ "Content-Type": "application/json",
1405
+ Authorization: `Bearer ${this._deviceJwt}`,
1406
+ },
1407
+ body: JSON.stringify({ webhook_url: this.config.webhookUrl }),
1408
+ });
1409
+ if (webhookResp.ok) {
1410
+ const webhookData = await webhookResp.json();
1411
+ console.log(`[SecureChannel] Webhook registered: ${this.config.webhookUrl} (secret: ${webhookData.webhook_secret?.slice(0, 8)}...)`);
1412
+ this.emit("webhook_registered", {
1413
+ url: this.config.webhookUrl,
1414
+ secret: webhookData.webhook_secret,
1415
+ });
1416
+ }
1417
+ else {
1418
+ console.warn(`[SecureChannel] Webhook registration failed: ${webhookResp.status}`);
1419
+ }
1420
+ }
1421
+ catch (err) {
1422
+ console.warn(`[SecureChannel] Webhook registration error: ${err}`);
1423
+ }
1424
+ }
1425
+ this._connect();
1426
+ }
1427
+ catch (err) {
1428
+ this._handleError(err);
1429
+ }
1430
+ }
1431
+ _connect() {
1432
+ if (this._stopped)
1433
+ return;
1434
+ // Clear reconnect timer — we're connecting now, so any pending reconnect is superseded.
1435
+ if (this._reconnectTimer) {
1436
+ clearTimeout(this._reconnectTimer);
1437
+ this._reconnectTimer = null;
1438
+ }
1439
+ // Clean up existing WebSocket to prevent reconnect cascade:
1440
+ // The server disconnects old WS when same device reconnects, which fires
1441
+ // the old close handler → _scheduleReconnect() → new _connect() → cascade.
1442
+ if (this._ws) {
1443
+ this._ws.removeAllListeners();
1444
+ try {
1445
+ this._ws.close();
1446
+ }
1447
+ catch { /* already closed */ }
1448
+ this._ws = null;
1449
+ }
1450
+ this._setState("connecting");
1451
+ const wsUrl = this.config.apiUrl.replace(/^http/, "ws");
1452
+ const url = `${wsUrl}/api/v1/ws?token=${encodeURIComponent(this._deviceJwt)}&device_id=${this._deviceId}`;
1453
+ const ws = new WebSocket(url);
1454
+ this._ws = ws;
1455
+ ws.on("open", async () => {
1456
+ try {
1457
+ this._reconnectAttempt = 0;
1458
+ this._lastWsOpenTime = Date.now();
1459
+ this._startPing(ws);
1460
+ this._startWakeDetector();
1461
+ this._startPendingPoll();
1462
+ // Sync missed messages before declaring ready
1463
+ await this._syncMissedMessages();
1464
+ await this._flushOutboundQueue();
1465
+ this._setState("ready");
1466
+ // Initialize client-side policy scanning if enabled
1467
+ if (this.config.enableScanning) {
1468
+ this._scanEngine = new ScanEngine();
1469
+ await this._fetchScanRules();
1470
+ }
1471
+ // Sync A2A channels from server (handles missed WS events after restart)
1472
+ try {
1473
+ await this.listA2AChannels();
1474
+ }
1475
+ catch (err) {
1476
+ console.warn("[SecureChannel] A2A channel sync failed (non-fatal):", err);
1477
+ }
1478
+ // Initialize telemetry reporter for agent-side metrics
1479
+ // (hubId may not be set yet — hub_identity_sync event will late-init if needed)
1480
+ if (!this._telemetryReporter && this._persisted?.deviceJwt && this._persisted?.hubId) {
1481
+ this._telemetryReporter = new TelemetryReporter({
1482
+ apiBase: this.config.apiUrl,
1483
+ hubId: this._persisted.hubId,
1484
+ authHeader: `Bearer ${this._persisted.deviceJwt}`,
1485
+ agentName: this.config.agentName ?? this._persisted.hubAddress,
1486
+ agentVersion: this.config.agentVersion ?? "0.0.0",
1487
+ });
1488
+ this._telemetryReporter.startAutoFlush(30_000);
1489
+ }
1490
+ this.emit("ready");
1491
+ }
1492
+ catch (openErr) {
1493
+ console.error("[SecureChannel] Error in WS open handler:", openErr);
1494
+ this.emit("error", openErr);
1495
+ }
1496
+ });
1497
+ ws.on("message", async (raw) => {
1498
+ this._lastServerMessage = Date.now();
1499
+ this._lastWakeTick = Date.now();
1500
+ try {
1501
+ const data = JSON.parse(raw.toString());
1502
+ if (data.event === "ping") {
1503
+ ws.send(JSON.stringify({ event: "pong" }));
1504
+ return;
1505
+ }
1506
+ if (data.event === "device_revoked") {
1507
+ await clearState(this.config.dataDir);
1508
+ this._handleError(new Error("Device was revoked"));
1509
+ return;
1510
+ }
1511
+ if (data.event === "device_linked") {
1512
+ await this._handleDeviceLinked(data.data);
1513
+ return;
1514
+ }
1515
+ if (data.event === "resync_request") {
1516
+ await this._handleResyncRequest(data.data);
1517
+ return;
1518
+ }
1519
+ if (data.event === "resync_ack") {
1520
+ // Agent doesn't need to handle its own ack echo
1521
+ return;
1522
+ }
1523
+ if (data.event === "message") {
1524
+ try {
1525
+ await this._handleIncomingMessage(data.data);
1526
+ }
1527
+ catch (msgErr) {
1528
+ console.error(`[SecureChannel] Message handler failed for conv ${data.data?.conversation_id?.slice(0, 8) ?? "?"}...:`, msgErr);
1529
+ }
1530
+ }
1531
+ if (data.event === "room_joined") {
1532
+ const d = data.data;
1533
+ this.joinRoom({
1534
+ roomId: d.room_id,
1535
+ name: d.room_name ?? d.name ?? "Room",
1536
+ members: (d.members || []).map((m) => ({
1537
+ deviceId: m.device_id,
1538
+ entityType: m.entity_type,
1539
+ displayName: m.display_name,
1540
+ identityPublicKey: m.identity_public_key,
1541
+ ephemeralPublicKey: m.ephemeral_public_key,
1542
+ })),
1543
+ conversations: (d.conversations || []).map((c) => ({
1544
+ id: c.id,
1545
+ participantA: c.participant_a,
1546
+ participantB: c.participant_b,
1547
+ })),
1548
+ forceRekey: d.force_rekey === true,
1549
+ }).catch((err) => this.emit("error", err));
1550
+ }
1551
+ if (data.event === "room_message") {
1552
+ try {
1553
+ await this._handleRoomMessage(data.data);
1554
+ }
1555
+ catch (rmErr) {
1556
+ console.error(`[SecureChannel] Room message handler failed:`, rmErr);
1557
+ }
1558
+ }
1559
+ if (data.event === "sender_key_distribution") {
1560
+ try {
1561
+ await this._handleSenderKeyDistribution(data.data);
1562
+ }
1563
+ catch (skdErr) {
1564
+ console.error("[SecureChannel] Sender key distribution handler failed:", skdErr);
1565
+ }
1566
+ }
1567
+ if (data.event === "room_message_sk") {
1568
+ try {
1569
+ await this._handleRoomMessageSK(data.data);
1570
+ }
1571
+ catch (rmskErr) {
1572
+ console.error("[SecureChannel] SK room message handler failed:", rmskErr);
1573
+ }
1574
+ }
1575
+ if (data.event === "room_participant_added") {
1576
+ const p = data.data;
1577
+ this.emit("room_participant_added", {
1578
+ roomId: p.room_id,
1579
+ participant: {
1580
+ deviceId: p.participant?.device_id ?? p.device_id,
1581
+ participantType: p.participant?.participant_type ?? p.participant_type ?? "human",
1582
+ displayName: p.participant?.display_name ?? p.display_name,
1583
+ },
1584
+ });
1585
+ }
1586
+ if (data.event === "room_participant_removed") {
1587
+ const p = data.data;
1588
+ this.emit("room_participant_removed", {
1589
+ roomId: p.room_id,
1590
+ participant: {
1591
+ deviceId: p.participant?.device_id ?? p.device_id,
1592
+ participantType: p.participant?.participant_type ?? p.participant_type ?? "human",
1593
+ displayName: p.participant?.display_name ?? p.display_name,
1594
+ },
1595
+ });
1596
+ }
1597
+ if (data.event === "policy_blocked") {
1598
+ this.emit("policy_blocked", data.data);
1599
+ }
1600
+ if (data.event === "message_held") {
1601
+ this.emit("message_held", data.data);
1602
+ }
1603
+ if (data.event === "policy_rejected") {
1604
+ this.emit("policy_rejected", data.data);
1605
+ }
1606
+ if (data.event === "scan_rules_updated") {
1607
+ await this._fetchScanRules();
1608
+ return;
1609
+ }
1610
+ // hub_identity_sync fires on every WS connect — ensures hubId is set for telemetry
1611
+ if (data.event === "hub_identity_sync") {
1612
+ if (this._persisted && data.data?.hub_id) {
1613
+ const changed = this._persisted.hubId !== data.data.hub_id;
1614
+ this._persisted.hubAddress = data.data.hub_address;
1615
+ this._persisted.hubId = data.data.hub_id;
1616
+ this._persisted.agentHubId = data.data.hub_id;
1617
+ if (changed)
1618
+ this._persistState();
1619
+ // Late-init telemetry reporter if it wasn't created during open handler
1620
+ if (!this._telemetryReporter && this._persisted.deviceJwt && this._persisted.hubId) {
1621
+ this._telemetryReporter = new TelemetryReporter({
1622
+ apiBase: this.config.apiUrl,
1623
+ hubId: this._persisted.hubId,
1624
+ authHeader: `Bearer ${this._persisted.deviceJwt}`,
1625
+ agentName: this.config.agentName ?? this._persisted.hubAddress,
1626
+ agentVersion: this.config.agentVersion ?? "0.0.0",
1627
+ });
1628
+ this._telemetryReporter.startAutoFlush(30_000);
1629
+ }
1630
+ }
1631
+ }
1632
+ if (data.event === "hub_identity_assigned") {
1633
+ if (this._persisted) {
1634
+ this._persisted.hubAddress = data.data.hub_address;
1635
+ this._persisted.hubId = data.data.hub_id;
1636
+ this._persistState();
1637
+ }
1638
+ this.emit("hub_identity_assigned", data.data);
1639
+ }
1640
+ if (data.event === "hub_identity_removed") {
1641
+ if (this._persisted) {
1642
+ delete this._persisted.hubAddress;
1643
+ delete this._persisted.hubId;
1644
+ this._persistState();
1645
+ }
1646
+ this.emit("hub_identity_removed", data.data);
1647
+ }
1648
+ // --- A2A Channel WS events ---
1649
+ if (data.event === "a2a_channel_approved") {
1650
+ const channelData = data.data || data;
1651
+ const channelId = channelData.channel_id;
1652
+ const convId = channelData.conversation_id;
1653
+ // Determine role: if we are the initiator hub address, we initiated
1654
+ const myHub = this._persisted?.hubAddress || "";
1655
+ const role = channelData.role ||
1656
+ (channelData.initiator_hub_address === myHub ? "initiator" : "responder");
1657
+ const peerHub = role === "initiator"
1658
+ ? channelData.responder_hub_address || ""
1659
+ : channelData.initiator_hub_address || "";
1660
+ // Store in persisted state
1661
+ if (this._persisted && convId) {
1662
+ if (!this._persisted.a2aChannels)
1663
+ this._persisted.a2aChannels = {};
1664
+ // Generate ephemeral X25519 keypair for key exchange
1665
+ const a2aEphemeral = await generateEphemeralKeypair();
1666
+ const ephPubHex = bytesToHex(a2aEphemeral.publicKey);
1667
+ const ephPrivHex = bytesToHex(a2aEphemeral.privateKey);
1668
+ this._persisted.a2aChannels[channelId] = {
1669
+ channelId,
1670
+ hubAddress: peerHub,
1671
+ conversationId: convId,
1672
+ role,
1673
+ pendingEphemeralPrivateKey: ephPrivHex,
1674
+ };
1675
+ // Send ephemeral public key to server for key exchange
1676
+ if (this._ws) {
1677
+ this._ws.send(JSON.stringify({
1678
+ event: "a2a_key_exchange",
1679
+ data: {
1680
+ channel_id: channelId,
1681
+ ephemeral_key: ephPubHex,
1682
+ },
1683
+ }));
1684
+ console.log(`[SecureChannel] A2A key exchange sent for channel ${channelId.slice(0, 8)}... (role=${role})`);
1685
+ }
1686
+ await this._persistState();
1687
+ }
1688
+ this.emit("a2a_channel_approved", channelData);
1689
+ // Notify gateway — channel is ready for messaging
1690
+ if (this.config.onA2AChannelReady && convId) {
1691
+ this.config.onA2AChannelReady({
1692
+ channelId,
1693
+ peerHubAddress: peerHub,
1694
+ role,
1695
+ conversationId: convId,
1696
+ topic: channelData.topic || undefined,
1697
+ });
1698
+ }
1699
+ }
1700
+ if (data.event === "a2a_channel_activated") {
1701
+ const actData = data.data || data;
1702
+ const channelId = actData.channel_id;
1703
+ const convId = actData.conversation_id;
1704
+ const activatedRole = actData.role;
1705
+ const peerIdentityHex = actData.peer_identity_key;
1706
+ const peerEphemeralHex = actData.peer_ephemeral_key;
1707
+ const channelEntry = this._persisted?.a2aChannels?.[channelId];
1708
+ if (channelEntry && channelEntry.pendingEphemeralPrivateKey && this._persisted) {
1709
+ try {
1710
+ // Reconstruct keys from hex
1711
+ const myIdentityPrivate = hexToBytes(this._persisted.identityKeypair.privateKey);
1712
+ const myIdentityPublic = hexToBytes(this._persisted.identityKeypair.publicKey);
1713
+ const myEphemeralPrivate = hexToBytes(channelEntry.pendingEphemeralPrivateKey);
1714
+ const theirIdentityPublic = hexToBytes(peerIdentityHex);
1715
+ const theirEphemeralPublic = hexToBytes(peerEphemeralHex);
1716
+ // Perform X3DH key agreement
1717
+ const sharedSecret = performX3DH({
1718
+ myIdentityPrivate,
1719
+ myEphemeralPrivate,
1720
+ theirIdentityPublic,
1721
+ theirEphemeralPublic,
1722
+ isInitiator: activatedRole === "initiator",
1723
+ });
1724
+ // Initialize Double Ratchet
1725
+ const identityKp = {
1726
+ publicKey: myIdentityPublic,
1727
+ privateKey: myIdentityPrivate,
1728
+ keyType: "ed25519",
1729
+ };
1730
+ const ratchet = activatedRole === "initiator"
1731
+ ? DoubleRatchet.initSender(sharedSecret, identityKp, theirIdentityPublic)
1732
+ : DoubleRatchet.initReceiver(sharedSecret, identityKp, theirIdentityPublic);
1733
+ // Store session state
1734
+ channelEntry.session = {
1735
+ ownerDeviceId: "", // A2A sessions don't have an owner device
1736
+ ratchetState: ratchet.serialize(),
1737
+ activated: activatedRole === "initiator", // initiator can send immediately
1738
+ };
1739
+ channelEntry.role = activatedRole;
1740
+ if (convId)
1741
+ channelEntry.conversationId = convId;
1742
+ // Clean up ephemeral private key (no longer needed)
1743
+ delete channelEntry.pendingEphemeralPrivateKey;
1744
+ await this._persistState();
1745
+ console.log(`[SecureChannel] A2A channel activated: ${channelId.slice(0, 8)}... (role=${activatedRole}, ` +
1746
+ `canSend=${activatedRole === "initiator"})`);
1747
+ }
1748
+ catch (err) {
1749
+ console.error(`[SecureChannel] A2A activation failed for ${channelId.slice(0, 8)}...:`, err);
1750
+ this.emit("error", err);
1751
+ }
1752
+ }
1753
+ this.emit("a2a_channel_activated", actData);
1754
+ }
1755
+ // --- Observer key exchange events ---
1756
+ if (data.event === "a2a_observer_enabled") {
1757
+ // Owner has enabled observation on this channel — submit observer ephemeral key
1758
+ const obsData = data.data || data;
1759
+ const obsChannelId = obsData.channel_id;
1760
+ const obsChannelEntry = this._persisted?.a2aChannels?.[obsChannelId];
1761
+ if (obsChannelEntry && this._persisted && this._ws) {
1762
+ try {
1763
+ const obsEphemeral = await generateEphemeralKeypair();
1764
+ const obsEphPubHex = bytesToHex(obsEphemeral.publicKey);
1765
+ const obsEphPrivHex = bytesToHex(obsEphemeral.privateKey);
1766
+ obsChannelEntry.pendingObserverEphemeralPrivateKey = obsEphPrivHex;
1767
+ await this._persistState();
1768
+ this._ws.send(JSON.stringify({
1769
+ event: "a2a_observer_key_submit",
1770
+ data: {
1771
+ channel_id: obsChannelId,
1772
+ ephemeral_key: obsEphPubHex,
1773
+ side: obsChannelEntry.role || "initiator",
1774
+ },
1775
+ }));
1776
+ console.log(`[SecureChannel] Observer key submitted for channel ${obsChannelId.slice(0, 8)}... (side=${obsChannelEntry.role})`);
1777
+ }
1778
+ catch (err) {
1779
+ console.error("[SecureChannel] Observer key submission failed:", err);
1780
+ }
1781
+ }
1782
+ }
1783
+ if (data.event === "a2a_observer_key_accepted") {
1784
+ // Server accepted our observer key and returned owner's identity key
1785
+ const obsAccData = data.data || data;
1786
+ const obsAccChannelId = obsAccData.channel_id;
1787
+ const observerIdentityHex = obsAccData.observer_identity_key;
1788
+ const obsAccSide = obsAccData.side;
1789
+ const obsAccEntry = this._persisted?.a2aChannels?.[obsAccChannelId];
1790
+ if (obsAccEntry &&
1791
+ obsAccEntry.pendingObserverEphemeralPrivateKey &&
1792
+ this._persisted) {
1793
+ try {
1794
+ const myIdentityPrivate = hexToBytes(this._persisted.identityKeypair.privateKey);
1795
+ const myIdentityPublic = hexToBytes(this._persisted.identityKeypair.publicKey);
1796
+ const myObsEphemeralPrivate = hexToBytes(obsAccEntry.pendingObserverEphemeralPrivateKey);
1797
+ const ownerIdentityPublic = hexToBytes(observerIdentityHex);
1798
+ // Perform X3DH: agent is always initiator toward owner
1799
+ const obsSharedSecret = performX3DH({
1800
+ myIdentityPrivate,
1801
+ myEphemeralPrivate: myObsEphemeralPrivate,
1802
+ theirIdentityPublic: ownerIdentityPublic,
1803
+ theirEphemeralPublic: ownerIdentityPublic, // owner uses identity as ephemeral
1804
+ isInitiator: true,
1805
+ });
1806
+ const identityKp = {
1807
+ publicKey: myIdentityPublic,
1808
+ privateKey: myIdentityPrivate,
1809
+ keyType: "ed25519",
1810
+ };
1811
+ const obsRatchet = DoubleRatchet.initSender(obsSharedSecret, identityKp, ownerIdentityPublic);
1812
+ obsAccEntry.observerSession = {
1813
+ ratchetState: obsRatchet.serialize(),
1814
+ };
1815
+ delete obsAccEntry.pendingObserverEphemeralPrivateKey;
1816
+ await this._persistState();
1817
+ console.log(`[SecureChannel] Observer ratchet initialized for channel ${obsAccChannelId.slice(0, 8)}... (side=${obsAccSide})`);
1818
+ }
1819
+ catch (err) {
1820
+ console.error("[SecureChannel] Observer ratchet init failed:", err);
1821
+ }
1822
+ }
1823
+ }
1824
+ if (data.event === "a2a_channel_rejected") {
1825
+ this.emit("a2a_channel_rejected", data.data || data);
1826
+ }
1827
+ if (data.event === "a2a_channel_revoked") {
1828
+ const channelData = data.data || data;
1829
+ const channelId = channelData.channel_id;
1830
+ // Remove from persisted state
1831
+ if (this._persisted?.a2aChannels?.[channelId]) {
1832
+ delete this._persisted.a2aChannels[channelId];
1833
+ await this._persistState();
1834
+ }
1835
+ this.emit("a2a_channel_revoked", channelData);
1836
+ }
1837
+ if (data.event === "a2a_message") {
1838
+ const msgData = data.data || data;
1839
+ const a2aMsgId = msgData.message_id;
1840
+ // Dedup: skip if already processed (direct + Redis double-delivery)
1841
+ if (a2aMsgId && this._a2aSeenMessageIds.has(a2aMsgId)) {
1842
+ return;
1843
+ }
1844
+ if (a2aMsgId) {
1845
+ this._a2aSeenMessageIds.add(a2aMsgId);
1846
+ // Evict oldest if buffer full
1847
+ if (this._a2aSeenMessageIds.size > SecureChannel.A2A_SEEN_MAX) {
1848
+ const first = this._a2aSeenMessageIds.values().next().value;
1849
+ if (first)
1850
+ this._a2aSeenMessageIds.delete(first);
1851
+ }
1852
+ }
1853
+ // --- Encrypted A2A message (has ciphertext field) ---
1854
+ if (msgData.ciphertext && msgData.header_blob) {
1855
+ const channelId = msgData.channel_id || "";
1856
+ const channelEntry = this._persisted?.a2aChannels?.[channelId];
1857
+ if (channelEntry?.session?.ratchetState) {
1858
+ try {
1859
+ // Reconstruct EncryptedMessage from hex transport
1860
+ const headerJson = Buffer.from(msgData.header_blob, "hex").toString("utf-8");
1861
+ const headerObj = JSON.parse(headerJson);
1862
+ const encryptedMessage = {
1863
+ header: {
1864
+ dhPublicKey: hexToBytes(headerObj.dhPublicKey),
1865
+ previousChainLength: headerObj.previousChainLength,
1866
+ messageNumber: headerObj.messageNumber,
1867
+ },
1868
+ headerSignature: hexToBytes(msgData.header_signature),
1869
+ ciphertext: hexToBytes(msgData.ciphertext),
1870
+ nonce: hexToBytes(msgData.nonce),
1871
+ };
1872
+ // Decrypt
1873
+ const ratchet = DoubleRatchet.deserialize(channelEntry.session.ratchetState);
1874
+ const ratchetSnapshot = channelEntry.session.ratchetState; // snapshot before decrypt
1875
+ let a2aPlaintext;
1876
+ try {
1877
+ a2aPlaintext = ratchet.decrypt(encryptedMessage);
1878
+ }
1879
+ catch (decryptErr) {
1880
+ console.error(`[SecureChannel] A2A decrypt failed — restoring ratchet state:`, decryptErr);
1881
+ channelEntry.session.ratchetState = ratchetSnapshot;
1882
+ return;
1883
+ }
1884
+ // Update ratchet state
1885
+ channelEntry.session.ratchetState = ratchet.serialize();
1886
+ // If responder and not yet activated, this is the first initiator message
1887
+ if (channelEntry.role === "responder" && !channelEntry.session.activated) {
1888
+ channelEntry.session.activated = true;
1889
+ console.log(`[SecureChannel] A2A responder ratchet activated for ${channelId.slice(0, 8)}...`);
1890
+ // Flush any queued messages
1891
+ const queued = this._a2aPendingQueue[channelId];
1892
+ if (queued && queued.length > 0) {
1893
+ delete this._a2aPendingQueue[channelId];
1894
+ console.log(`[SecureChannel] Flushing ${queued.length} queued A2A message(s)`);
1895
+ // Flush asynchronously after persisting current state
1896
+ await this._persistState();
1897
+ for (const pending of queued) {
1898
+ try {
1899
+ await this.sendToAgent(channelEntry.hubAddress, pending.text, pending.opts);
1900
+ }
1901
+ catch (flushErr) {
1902
+ console.error("[SecureChannel] Failed to flush queued A2A message:", flushErr);
1903
+ }
1904
+ }
1905
+ }
1906
+ }
1907
+ await this._persistState();
1908
+ const a2aMsg = {
1909
+ text: a2aPlaintext,
1910
+ fromHubAddress: msgData.from_hub_address || msgData.hub_address || "",
1911
+ channelId,
1912
+ conversationId: msgData.conversation_id || "",
1913
+ parentSpanId: msgData.parent_span_id,
1914
+ timestamp: msgData.timestamp || new Date().toISOString(),
1915
+ };
1916
+ this.emit("a2a_message", a2aMsg);
1917
+ if (this.config.onA2AMessage) {
1918
+ this.config.onA2AMessage(a2aMsg);
1919
+ }
1920
+ }
1921
+ catch (decryptErr) {
1922
+ console.error(`[SecureChannel] A2A decrypt failed for channel ${channelId.slice(0, 8)}...:`, decryptErr);
1923
+ this.emit("error", decryptErr);
1924
+ }
1925
+ }
1926
+ else {
1927
+ console.warn(`[SecureChannel] Received encrypted A2A message but no session for channel ${channelId}`);
1928
+ }
1929
+ }
1930
+ else {
1931
+ // --- Legacy plaintext A2A message ---
1932
+ const a2aMsg = {
1933
+ text: msgData.plaintext || msgData.text,
1934
+ fromHubAddress: msgData.from_hub_address || msgData.hub_address || "",
1935
+ channelId: msgData.channel_id || "",
1936
+ conversationId: msgData.conversation_id || "",
1937
+ parentSpanId: msgData.parent_span_id,
1938
+ timestamp: msgData.timestamp || new Date().toISOString(),
1939
+ };
1940
+ this.emit("a2a_message", a2aMsg);
1941
+ if (this.config.onA2AMessage) {
1942
+ this.config.onA2AMessage(a2aMsg);
1943
+ }
1944
+ }
1945
+ }
1946
+ }
1947
+ catch (err) {
1948
+ this.emit("error", err);
1949
+ }
1950
+ });
1951
+ ws.on("close", () => {
1952
+ this._stopPing();
1953
+ this._stopWakeDetector();
1954
+ this._stopPendingPoll();
1955
+ if (this._stopped)
1956
+ return;
1957
+ this._setState("disconnected");
1958
+ this._scheduleReconnect();
1959
+ });
1960
+ ws.on("error", (err) => {
1961
+ this.emit("error", err);
1962
+ // The close event will fire after this and handle reconnection
1963
+ });
1964
+ }
1965
+ /**
1966
+ * Handle an incoming encrypted message from a specific conversation.
1967
+ * Decrypts using the appropriate session ratchet, emits to the agent,
1968
+ * and relays as sync messages to sibling sessions.
1969
+ */
1970
+ async _handleIncomingMessage(msgData) {
1971
+ // Don't decrypt our own messages
1972
+ if (msgData.sender_device_id === this._deviceId)
1973
+ return;
1974
+ // Dedup: skip if this message was already processed during sync
1975
+ if (this._syncMessageIds?.has(msgData.message_id)) {
1976
+ return;
1977
+ }
1978
+ const convId = msgData.conversation_id;
1979
+ const session = this._sessions.get(convId);
1980
+ if (!session) {
1981
+ // No session for this conversation — trigger resync to establish one
1982
+ console.warn(`[SecureChannel] No session for conversation ${convId}, requesting resync`);
1983
+ const RESYNC_COOLDOWN_MS = 5 * 60 * 1000;
1984
+ const lastResync = this._lastResyncRequest.get(convId) ?? 0;
1985
+ if (Date.now() - lastResync > RESYNC_COOLDOWN_MS &&
1986
+ this._ws &&
1987
+ this._persisted) {
1988
+ this._lastResyncRequest.set(convId, Date.now());
1989
+ this._ws.send(JSON.stringify({
1990
+ event: "resync_request",
1991
+ data: {
1992
+ conversation_id: convId,
1993
+ reason: "no_session",
1994
+ identity_public_key: this._persisted.identityKeypair.publicKey,
1995
+ ephemeral_public_key: this._persisted.ephemeralKeypair.publicKey,
1996
+ },
1997
+ }));
1998
+ this.emit("resync_requested", {
1999
+ conversationId: convId,
2000
+ reason: "no_session",
2001
+ });
2002
+ }
2003
+ return;
2004
+ }
2005
+ const encrypted = transportToEncryptedMessage({
2006
+ header_blob: msgData.header_blob,
2007
+ ciphertext: msgData.ciphertext,
2008
+ });
2009
+ const ratchetSnapshot = session.ratchet.serialize();
2010
+ let plaintext;
2011
+ try {
2012
+ plaintext = session.ratchet.decrypt(encrypted);
2013
+ }
2014
+ catch (decryptErr) {
2015
+ console.error(`[SecureChannel] Decrypt failed for conv ${convId.slice(0, 8)}... — restoring ratchet:`, decryptErr);
2016
+ try {
2017
+ session.ratchet = DoubleRatchet.deserialize(ratchetSnapshot);
2018
+ }
2019
+ catch (restoreErr) {
2020
+ console.error("[SecureChannel] Ratchet restore failed:", restoreErr);
2021
+ }
2022
+ // Initiate resync if within cooldown window
2023
+ const RESYNC_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
2024
+ const lastResync = this._lastResyncRequest.get(convId) ?? 0;
2025
+ if (Date.now() - lastResync > RESYNC_COOLDOWN_MS && this._ws && this._persisted) {
2026
+ this._lastResyncRequest.set(convId, Date.now());
2027
+ const idPubHex = this._persisted.identityKeypair.publicKey;
2028
+ const ephPubHex = this._persisted.ephemeralKeypair.publicKey;
2029
+ this._ws.send(JSON.stringify({
2030
+ event: "resync_request",
2031
+ data: {
2032
+ conversation_id: convId,
2033
+ reason: "decrypt_failure",
2034
+ identity_public_key: idPubHex,
2035
+ ephemeral_public_key: ephPubHex,
2036
+ },
2037
+ }));
2038
+ console.log(`[SecureChannel] Sent resync_request for conv ${convId.slice(0, 8)}...`);
2039
+ this.emit("resync_requested", { conversationId: convId, reason: "decrypt_failure" });
2040
+ }
2041
+ return;
2042
+ }
2043
+ // ACK delivery to server
2044
+ this._sendAck(msgData.message_id);
2045
+ // Mark session as activated on first owner message
2046
+ if (!session.activated) {
2047
+ session.activated = true;
2048
+ console.log(`[SecureChannel] Session ${convId.slice(0, 8)}... activated by first owner message`);
2049
+ }
2050
+ // Parse structured payload (new format) or treat as raw text (legacy)
2051
+ let messageText;
2052
+ let messageType;
2053
+ let attachmentInfo = null;
2054
+ try {
2055
+ const parsed = JSON.parse(plaintext);
2056
+ messageType = parsed.type || "message";
2057
+ messageText = parsed.text || plaintext;
2058
+ if (parsed.attachment) {
2059
+ attachmentInfo = parsed.attachment;
2060
+ }
2061
+ }
2062
+ catch {
2063
+ // Legacy plain string format
2064
+ messageType = "message";
2065
+ messageText = plaintext;
2066
+ }
2067
+ if (messageType === "session_init") {
2068
+ // New device session activated — replay message history
2069
+ console.log(`[SecureChannel] session_init received for ${convId.slice(0, 8)}..., replaying history`);
2070
+ await this._replayHistoryToSession(convId);
2071
+ await this._persistState();
2072
+ return;
2073
+ }
2074
+ // --- Workspace file operations (no scan — these are structured commands) ---
2075
+ if (messageType === "workspace_file_upload") {
2076
+ try {
2077
+ const parsed = JSON.parse(plaintext);
2078
+ const workspaceDir = this._resolveWorkspaceDir();
2079
+ const { handleWorkspaceUpload } = await import("./workspace-handlers.js");
2080
+ const result = await handleWorkspaceUpload(parsed, workspaceDir);
2081
+ await this._sendStructuredReply(convId, { type: "workspace_file_ack", filename: parsed.filename, ...result });
2082
+ }
2083
+ catch (err) {
2084
+ await this._sendStructuredReply(convId, { type: "workspace_file_ack", status: "error", error: err.message });
2085
+ }
2086
+ return;
2087
+ }
2088
+ if (messageType === "workspace_file_list") {
2089
+ try {
2090
+ const workspaceDir = this._resolveWorkspaceDir();
2091
+ const { handleWorkspaceList } = await import("./workspace-handlers.js");
2092
+ const result = await handleWorkspaceList(workspaceDir);
2093
+ await this._sendStructuredReply(convId, { type: "workspace_file_list_response", ...result });
2094
+ }
2095
+ catch (err) {
2096
+ await this._sendStructuredReply(convId, { type: "workspace_file_list_response", files: [], error: err.message });
2097
+ }
2098
+ return;
2099
+ }
2100
+ if (messageType === "workspace_file_read") {
2101
+ try {
2102
+ const parsed = JSON.parse(plaintext);
2103
+ const workspaceDir = this._resolveWorkspaceDir();
2104
+ const { handleWorkspaceRead } = await import("./workspace-handlers.js");
2105
+ const result = await handleWorkspaceRead(parsed, workspaceDir);
2106
+ await this._sendStructuredReply(convId, { type: "workspace_file_read_response", ...result });
2107
+ }
2108
+ catch (err) {
2109
+ await this._sendStructuredReply(convId, { type: "workspace_file_read_response", error: err.message });
2110
+ }
2111
+ return;
2112
+ }
2113
+ // Inbound scan — after decryption
2114
+ if (this._scanEngine) {
2115
+ const scanResult = this._scanEngine.scanInbound(messageText);
2116
+ if (scanResult.status === "blocked") {
2117
+ this.emit("scan_blocked", { direction: "inbound", violations: scanResult.violations });
2118
+ return; // drop the message
2119
+ }
2120
+ }
2121
+ if (messageType === "message") {
2122
+ const topicId = msgData.topic_id;
2123
+ // Track last inbound topic so replies default to the correct channel
2124
+ this._lastIncomingTopicId = topicId;
2125
+ // Handle attachment: download, decrypt, save to disk
2126
+ let attachData;
2127
+ if (attachmentInfo) {
2128
+ try {
2129
+ const { filePath, decrypted } = await this._downloadAndDecryptAttachment(attachmentInfo);
2130
+ attachData = {
2131
+ filename: attachmentInfo.filename,
2132
+ mime: attachmentInfo.mime,
2133
+ size: decrypted.length,
2134
+ filePath,
2135
+ };
2136
+ // For images: include base64 so LLM vision can see the content
2137
+ if (attachmentInfo.mime.startsWith("image/")) {
2138
+ attachData.base64 = `data:${attachmentInfo.mime};base64,${bytesToBase64(decrypted)}`;
2139
+ }
2140
+ // For text-based files: extract content inline
2141
+ const textMimes = ["text/", "application/json", "application/xml", "application/csv"];
2142
+ if (textMimes.some((m) => attachmentInfo.mime.startsWith(m))) {
2143
+ attachData.textContent = new TextDecoder().decode(decrypted);
2144
+ }
2145
+ }
2146
+ catch (err) {
2147
+ console.error(`[SecureChannel] Failed to download attachment:`, err);
2148
+ }
2149
+ }
2150
+ // Store owner message in history for cross-device replay
2151
+ this._appendHistory("owner", messageText, topicId);
2152
+ // Build display text with attachment content
2153
+ let emitText = messageText;
2154
+ if (attachData) {
2155
+ if (attachData.textContent) {
2156
+ emitText = `[Attachment: ${attachData.filename} (${attachData.mime}) saved to ${attachData.filePath}]\n---\n${attachData.textContent}\n---\n\n${messageText}`;
2157
+ }
2158
+ else if (attachData.base64) {
2159
+ emitText = `[Image attachment: ${attachData.filename} saved to ${attachData.filePath}]\nUse your Read tool to view this image file.\n\n${messageText}`;
2160
+ }
2161
+ else {
2162
+ emitText = `[Attachment: ${attachData.filename} saved to ${attachData.filePath}]\n\n${messageText}`;
2163
+ }
2164
+ }
2165
+ // Resolve room membership — check if this conversation belongs to a room
2166
+ let roomId;
2167
+ if (this._persisted?.rooms) {
2168
+ for (const [rid, room] of Object.entries(this._persisted.rooms)) {
2169
+ if (room.conversationIds?.includes(convId)) {
2170
+ roomId = rid;
2171
+ break;
2172
+ }
2173
+ }
2174
+ }
2175
+ // Update room context when a room message arrives (sticky — never cleared by 1:1 traffic)
2176
+ if (roomId)
2177
+ this._lastInboundRoomId = roomId;
2178
+ // Emit to agent developer
2179
+ const metadata = {
2180
+ messageId: msgData.message_id,
2181
+ conversationId: convId,
2182
+ timestamp: msgData.created_at,
2183
+ topicId,
2184
+ attachment: attachData,
2185
+ spanId: msgData.span_id,
2186
+ parentSpanId: msgData.parent_span_id,
2187
+ messageType: msgData.message_type ?? "text",
2188
+ priority: msgData.priority ?? "normal",
2189
+ envelopeVersion: msgData.envelope_version ?? "1.0.0",
2190
+ roomId,
2191
+ };
2192
+ this.emit("message", emitText, metadata);
2193
+ Promise.resolve(this.config.onMessage?.(emitText, metadata)).catch((err) => {
2194
+ console.error("[SecureChannel] onMessage callback error:", err);
2195
+ });
2196
+ // Relay to sibling sessions as sync messages
2197
+ await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText, topicId);
2198
+ }
2199
+ // If type === "sync", agent ignores it (sync is for owner devices only)
2200
+ // Track last message timestamp for offline sync
2201
+ if (this._persisted) {
2202
+ this._persisted.lastMessageTimestamp = msgData.created_at;
2203
+ }
2204
+ // Persist all ratchet states after decrypt
2205
+ await this._persistState();
2206
+ }
2207
+ /**
2208
+ * Download an encrypted attachment blob, decrypt it, verify integrity,
2209
+ * and save the plaintext file to disk.
2210
+ */
2211
+ async _downloadAndDecryptAttachment(info) {
2212
+ const attachDir = join(this.config.dataDir, "attachments");
2213
+ await mkdir(attachDir, { recursive: true });
2214
+ // Download encrypted blob from API
2215
+ const url = `${this.config.apiUrl}${info.blobUrl}`;
2216
+ const res = await fetch(url, {
2217
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
2218
+ });
2219
+ if (!res.ok) {
2220
+ throw new Error(`Attachment download failed: ${res.status}`);
2221
+ }
2222
+ const buffer = await res.arrayBuffer();
2223
+ const encryptedData = new Uint8Array(buffer);
2224
+ // Verify integrity
2225
+ const digest = computeFileDigest(encryptedData);
2226
+ if (digest !== info.digest) {
2227
+ throw new Error("Attachment digest mismatch — possible tampering");
2228
+ }
2229
+ // Decrypt
2230
+ const fileKey = base64ToBytes(info.fileKey);
2231
+ const fileNonce = base64ToBytes(info.fileNonce);
2232
+ const decrypted = decryptFile(encryptedData, fileKey, fileNonce);
2233
+ // Save to disk
2234
+ const filePath = join(attachDir, info.filename);
2235
+ await writeFile(filePath, decrypted);
2236
+ console.log(`[SecureChannel] Attachment saved: ${filePath} (${decrypted.length} bytes)`);
2237
+ return { filePath, decrypted };
2238
+ }
2239
+ /**
2240
+ * Upload an attachment file: encrypt, upload to server, return metadata
2241
+ * for inclusion in the message envelope.
2242
+ */
2243
+ async _uploadAttachment(filePath, conversationId) {
2244
+ const data = await readFile(filePath);
2245
+ const plainData = new Uint8Array(data);
2246
+ const result = encryptFile(plainData);
2247
+ // Upload encrypted blob via multipart form
2248
+ const { Blob: NodeBlob, FormData: NodeFormData } = await import("node:buffer").then(() => globalThis);
2249
+ const formData = new FormData();
2250
+ formData.append("conversation_id", conversationId);
2251
+ formData.append("file", new Blob([result.encryptedData.buffer], { type: "application/octet-stream" }), "attachment.bin");
2252
+ const res = await fetch(`${this.config.apiUrl}/api/v1/attachments/upload`, {
2253
+ method: "POST",
2254
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
2255
+ body: formData,
2256
+ });
2257
+ if (!res.ok) {
2258
+ const detail = await res.text();
2259
+ throw new Error(`Attachment upload failed (${res.status}): ${detail}`);
2260
+ }
2261
+ const resp = await res.json();
2262
+ const filename = filePath.split("/").pop() || "file";
2263
+ return {
2264
+ blobId: resp.blob_id,
2265
+ blobUrl: resp.blob_url,
2266
+ fileKey: bytesToBase64(result.fileKey),
2267
+ fileNonce: bytesToBase64(result.fileNonce),
2268
+ digest: result.digest,
2269
+ filename,
2270
+ mime: "application/octet-stream",
2271
+ size: plainData.length,
2272
+ };
2273
+ }
2274
+ /**
2275
+ * Send a message with an attached file. Encrypts the file, uploads it,
2276
+ * then sends the envelope with attachment metadata via Double Ratchet.
2277
+ */
2278
+ async sendWithAttachment(plaintext, filePath, options) {
2279
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
2280
+ throw new Error("Channel is not ready");
2281
+ }
2282
+ const topicId = options?.topicId ?? this._persisted?.defaultTopicId;
2283
+ // Upload attachment using primary conversation for the blob
2284
+ const attachMeta = await this._uploadAttachment(filePath, this._primaryConversationId);
2285
+ // Build envelope
2286
+ const envelope = JSON.stringify({
2287
+ type: "message",
2288
+ text: plaintext,
2289
+ topicId,
2290
+ attachment: attachMeta,
2291
+ });
2292
+ // Store agent message in history
2293
+ this._appendHistory("agent", plaintext, topicId);
2294
+ const messageGroupId = randomUUID();
2295
+ for (const [convId, session] of this._sessions) {
2296
+ if (!session.activated)
2297
+ continue;
2298
+ const encrypted = session.ratchet.encrypt(envelope);
2299
+ const transport = encryptedMessageToTransport(encrypted);
2300
+ this._ws.send(JSON.stringify({
2301
+ event: "message",
2302
+ data: {
2303
+ conversation_id: convId,
2304
+ header_blob: transport.header_blob,
2305
+ ciphertext: transport.ciphertext,
2306
+ message_group_id: messageGroupId,
2307
+ topic_id: topicId,
2308
+ },
2309
+ }));
2310
+ }
2311
+ await this._persistState();
2312
+ }
2313
+ /**
2314
+ * Relay an owner's message to all sibling sessions as encrypted sync messages.
2315
+ * This allows all owner devices to see messages from any single device.
2316
+ */
2317
+ async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText, topicId) {
2318
+ if (!this._ws || this._sessions.size <= 1)
2319
+ return;
2320
+ // Build set of room conversation IDs to exclude from sync relay
2321
+ // (room pairwise sessions should NOT receive 1:1 sync messages)
2322
+ const roomConvIds = new Set();
2323
+ if (this._persisted?.rooms) {
2324
+ for (const room of Object.values(this._persisted.rooms)) {
2325
+ for (const cid of room.conversationIds) {
2326
+ roomConvIds.add(cid);
2327
+ }
2328
+ }
2329
+ }
2330
+ const syncPayload = JSON.stringify({
2331
+ type: "sync",
2332
+ sender: senderOwnerDeviceId,
2333
+ text: messageText,
2334
+ ts: new Date().toISOString(),
2335
+ topicId,
2336
+ });
2337
+ for (const [siblingConvId, siblingSession] of this._sessions) {
2338
+ if (siblingConvId === sourceConvId)
2339
+ continue;
2340
+ // Don't relay to sessions where owner hasn't sent their first message yet
2341
+ if (!siblingSession.activated)
2342
+ continue;
2343
+ // Skip room pairwise sessions — those use sendToRoom()
2344
+ if (roomConvIds.has(siblingConvId))
2345
+ continue;
2346
+ const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
2347
+ const syncTransport = encryptedMessageToTransport(syncEncrypted);
2348
+ this._ws.send(JSON.stringify({
2349
+ event: "message",
2350
+ data: {
2351
+ conversation_id: siblingConvId,
2352
+ header_blob: syncTransport.header_blob,
2353
+ ciphertext: syncTransport.ciphertext,
2354
+ },
2355
+ }));
2356
+ }
2357
+ }
2358
+ /**
2359
+ * Resolve the agent's workspace directory.
2360
+ * Looks for OpenClaw workspace config, falls back to default path.
2361
+ */
2362
+ _resolveWorkspaceDir() {
2363
+ const homedir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
2364
+ const agentName = this.config.agentName;
2365
+ // Try to read OpenClaw config for workspace path
2366
+ try {
2367
+ const configPath = join(homedir, ".openclaw", "openclaw.json");
2368
+ const raw = require("node:fs").readFileSync(configPath, "utf-8");
2369
+ const config = JSON.parse(raw);
2370
+ const agents = config?.agents?.list;
2371
+ if (Array.isArray(agents) && agentName) {
2372
+ // Find the agent matching this account by id or name
2373
+ for (const agent of agents) {
2374
+ if ((agent.id === agentName || agent.name === agentName) &&
2375
+ agent.workspace && typeof agent.workspace === "string") {
2376
+ return agent.workspace;
2377
+ }
2378
+ }
2379
+ }
2380
+ }
2381
+ catch {
2382
+ // Config not found or unreadable — fall through to default
2383
+ }
2384
+ // Fallback: OpenClaw convention is ~/.openclaw/workspace-<name>/
2385
+ if (agentName && agentName !== "CLI Agent" && agentName !== "OpenClaw Agent") {
2386
+ return join(homedir, ".openclaw", `workspace-${agentName}`);
2387
+ }
2388
+ // Last resort: default workspace
2389
+ return join(homedir, ".openclaw", "workspace");
2390
+ }
2391
+ /**
2392
+ * Send a structured JSON reply to a specific conversation.
2393
+ * Encrypts the payload via the conversation's ratchet and sends via WebSocket.
2394
+ */
2395
+ async _sendStructuredReply(convId, payload) {
2396
+ const session = this._sessions.get(convId);
2397
+ if (!session || !this._ws) {
2398
+ console.warn(`[SecureChannel] Cannot send structured reply — no session for ${convId.slice(0, 8)}...`);
2399
+ return;
2400
+ }
2401
+ const plaintext = JSON.stringify(payload);
2402
+ const encrypted = session.ratchet.encrypt(plaintext);
2403
+ const transport = encryptedMessageToTransport(encrypted);
2404
+ this._ws.send(JSON.stringify({
2405
+ event: "message",
2406
+ data: {
2407
+ conversation_id: convId,
2408
+ header_blob: transport.header_blob,
2409
+ ciphertext: transport.ciphertext,
2410
+ },
2411
+ }));
2412
+ await this._persistState();
2413
+ }
2414
+ /**
2415
+ * Send stored message history to a newly-activated session.
2416
+ * Batches all history into a single encrypted message.
2417
+ */
2418
+ async _replayHistoryToSession(convId) {
2419
+ const session = this._sessions.get(convId);
2420
+ if (!session || !session.activated || !this._ws)
2421
+ return;
2422
+ const history = this._persisted?.messageHistory ?? [];
2423
+ if (history.length === 0) {
2424
+ console.log(`[SecureChannel] No history to replay for ${convId.slice(0, 8)}...`);
2425
+ return;
2426
+ }
2427
+ console.log(`[SecureChannel] Replaying ${history.length} messages to session ${convId.slice(0, 8)}...`);
2428
+ const replayPayload = JSON.stringify({
2429
+ type: "history_replay",
2430
+ messages: history,
2431
+ });
2432
+ const encrypted = session.ratchet.encrypt(replayPayload);
2433
+ const transport = encryptedMessageToTransport(encrypted);
2434
+ this._ws.send(JSON.stringify({
2435
+ event: "message",
2436
+ data: {
2437
+ conversation_id: convId,
2438
+ header_blob: transport.header_blob,
2439
+ ciphertext: transport.ciphertext,
2440
+ },
2441
+ }));
2442
+ }
2443
+ /**
2444
+ * Handle a device_linked event: a new owner device has joined.
2445
+ * Fetches the new device's public keys, performs X3DH, and initializes
2446
+ * a new ratchet session.
2447
+ */
2448
+ async _handleDeviceLinked(event) {
2449
+ console.log(`[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`);
2450
+ try {
2451
+ if (!event.owner_identity_public_key) {
2452
+ console.error(`[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`);
2453
+ return;
2454
+ }
2455
+ const identity = this._persisted.identityKeypair;
2456
+ const ephemeral = this._persisted.ephemeralKeypair;
2457
+ // Perform X3DH as responder with the new owner device
2458
+ const sharedSecret = performX3DH({
2459
+ myIdentityPrivate: hexToBytes(identity.privateKey),
2460
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
2461
+ theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
2462
+ theirEphemeralPublic: hexToBytes(event.owner_ephemeral_public_key ?? event.owner_identity_public_key),
2463
+ isInitiator: false,
2464
+ });
2465
+ // Initialize ratchet as receiver
2466
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
2467
+ publicKey: hexToBytes(identity.publicKey),
2468
+ privateKey: hexToBytes(identity.privateKey),
2469
+ keyType: "ed25519",
2470
+ }, hexToBytes(event.owner_identity_public_key));
2471
+ this._sessions.set(event.conversation_id, {
2472
+ ownerDeviceId: event.owner_device_id,
2473
+ ratchet,
2474
+ activated: false, // Wait for owner's first message
2475
+ });
2476
+ // Persist
2477
+ this._persisted.sessions[event.conversation_id] = {
2478
+ ownerDeviceId: event.owner_device_id,
2479
+ ratchetState: ratchet.serialize(),
2480
+ activated: false,
2481
+ };
2482
+ // Persist owner hub_id if provided
2483
+ if (event.owner_hub_id) {
2484
+ this._persisted.ownerHubId = event.owner_hub_id;
2485
+ }
2486
+ await this._persistState();
2487
+ console.log(`[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`);
2488
+ }
2489
+ catch (err) {
2490
+ console.error(`[SecureChannel] Failed to handle device_linked:`, err);
2491
+ this.emit("error", err);
2492
+ }
2493
+ }
2494
+ /**
2495
+ * Handle a resync_request from the owner (owner-initiated ratchet re-establishment).
2496
+ * Re-derives shared secret via X3DH as responder, initializes fresh receiver ratchet,
2497
+ * and sends resync_ack back with agent's public keys.
2498
+ */
2499
+ async _handleResyncRequest(data) {
2500
+ const convId = data.conversation_id;
2501
+ console.log(`[SecureChannel] Received resync_request for conv ${convId.slice(0, 8)}... (reason: ${data.reason ?? "unknown"})`);
2502
+ try {
2503
+ if (!this._persisted) {
2504
+ console.error("[SecureChannel] Cannot handle resync — no persisted state");
2505
+ return;
2506
+ }
2507
+ const identity = this._persisted.identityKeypair;
2508
+ const ephemeral = this._persisted.ephemeralKeypair;
2509
+ // Perform X3DH as responder (agent is always responder in 1:1)
2510
+ const sharedSecret = performX3DH({
2511
+ myIdentityPrivate: hexToBytes(identity.privateKey),
2512
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
2513
+ theirIdentityPublic: hexToBytes(data.identity_public_key),
2514
+ theirEphemeralPublic: hexToBytes(data.ephemeral_public_key),
2515
+ isInitiator: false,
2516
+ });
2517
+ // Initialize fresh receiver ratchet
2518
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
2519
+ publicKey: hexToBytes(identity.publicKey),
2520
+ privateKey: hexToBytes(identity.privateKey),
2521
+ keyType: "ed25519",
2522
+ }, hexToBytes(data.identity_public_key));
2523
+ // Update session
2524
+ const existingSession = this._sessions.get(convId);
2525
+ const ownerDeviceId = data.sender_device_id ?? existingSession?.ownerDeviceId ?? "";
2526
+ this._sessions.set(convId, {
2527
+ ownerDeviceId,
2528
+ ratchet,
2529
+ activated: false, // Wait for owner's encrypted session_init
2530
+ });
2531
+ // Persist
2532
+ this._persisted.sessions[convId] = {
2533
+ ownerDeviceId,
2534
+ ratchetState: ratchet.serialize(),
2535
+ activated: false,
2536
+ };
2537
+ await this._persistState();
2538
+ // Send resync_ack with agent's public keys
2539
+ if (this._ws) {
2540
+ this._ws.send(JSON.stringify({
2541
+ event: "resync_ack",
2542
+ data: {
2543
+ conversation_id: convId,
2544
+ identity_public_key: identity.publicKey,
2545
+ ephemeral_public_key: ephemeral.publicKey,
2546
+ },
2547
+ }));
2548
+ }
2549
+ console.log(`[SecureChannel] Resync complete for conv ${convId.slice(0, 8)}... — waiting for owner session_init`);
2550
+ this.emit("resync_completed", { conversationId: convId });
2551
+ }
2552
+ catch (err) {
2553
+ console.error(`[SecureChannel] Resync failed for conv ${convId.slice(0, 8)}...:`, err);
2554
+ this.emit("error", err);
2555
+ }
2556
+ }
2557
+ /**
2558
+ * Handle an incoming room message. Finds the pairwise conversation
2559
+ * for the sender, decrypts, and emits a room_message event.
2560
+ */
2561
+ async _handleRoomMessage(msgData) {
2562
+ // Don't decrypt our own messages
2563
+ if (msgData.sender_device_id === this._deviceId)
2564
+ return;
2565
+ // Track room context so HTTP /send and OpenClaw tools auto-route to this room
2566
+ this._lastInboundRoomId = msgData.room_id;
2567
+ const convId = msgData.conversation_id ??
2568
+ this._findConversationForSender(msgData.sender_device_id, msgData.room_id);
2569
+ if (!convId) {
2570
+ console.warn(`[SecureChannel] No conversation found for sender ${msgData.sender_device_id.slice(0, 8)}... in room ${msgData.room_id}`);
2571
+ return;
2572
+ }
2573
+ let session = this._sessions.get(convId);
2574
+ if (!session) {
2575
+ // Unknown conversation — possibly a new member joined the room
2576
+ // after we connected. Fetch updated room data and create a session.
2577
+ console.warn(`[SecureChannel] No session for room conv ${convId.slice(0, 8)}..., fetching room data`);
2578
+ try {
2579
+ const roomRes = await fetch(`${this.config.apiUrl}/api/v1/rooms/${msgData.room_id}`, {
2580
+ headers: {
2581
+ Authorization: `Bearer ${this._persisted.deviceJwt}`,
2582
+ },
2583
+ });
2584
+ if (roomRes.ok) {
2585
+ const roomData = await roomRes.json();
2586
+ await this.joinRoom({
2587
+ roomId: roomData.id,
2588
+ name: roomData.name,
2589
+ members: (roomData.members || []).map((m) => ({
2590
+ deviceId: m.device_id,
2591
+ entityType: m.entity_type,
2592
+ displayName: m.display_name,
2593
+ identityPublicKey: m.identity_public_key,
2594
+ ephemeralPublicKey: m.ephemeral_public_key,
2595
+ })),
2596
+ conversations: (roomData.conversations || []).map((c) => ({
2597
+ id: c.id,
2598
+ participantA: c.participant_a,
2599
+ participantB: c.participant_b,
2600
+ })),
2601
+ });
2602
+ session = this._sessions.get(convId);
2603
+ }
2604
+ }
2605
+ catch (fetchErr) {
2606
+ console.error(`[SecureChannel] Failed to fetch room data for ${msgData.room_id}:`, fetchErr);
2607
+ }
2608
+ if (!session) {
2609
+ console.warn(`[SecureChannel] Still no session for room conv ${convId.slice(0, 8)}... after refresh, skipping`);
2610
+ return;
2611
+ }
2612
+ }
2613
+ const encrypted = transportToEncryptedMessage({
2614
+ header_blob: msgData.header_blob,
2615
+ ciphertext: msgData.ciphertext,
2616
+ });
2617
+ let plaintext;
2618
+ const ratchetSnapshot = session.ratchet.serialize();
2619
+ try {
2620
+ plaintext = session.ratchet.decrypt(encrypted);
2621
+ }
2622
+ catch (decryptErr) {
2623
+ // Restore ratchet to pre-decrypt state before attempting re-init
2624
+ try {
2625
+ session.ratchet = DoubleRatchet.deserialize(ratchetSnapshot);
2626
+ }
2627
+ catch { }
2628
+ // Ratchet desync — re-initialize from X3DH and retry once
2629
+ console.warn(`[SecureChannel] Room decrypt failed for conv ${convId.slice(0, 8)}...: ${String(decryptErr)}, re-initializing ratchet`);
2630
+ try {
2631
+ const roomEntry = this._persisted?.rooms
2632
+ ? Object.values(this._persisted.rooms).find((r) => r.conversationIds.includes(convId))
2633
+ : null;
2634
+ if (!roomEntry)
2635
+ throw new Error("Room not found for conversation");
2636
+ const otherMember = roomEntry.members.find((m) => m.deviceId === msgData.sender_device_id);
2637
+ if (!otherMember?.identityPublicKey)
2638
+ throw new Error("No key for sender");
2639
+ const isInitiator = this._deviceId < msgData.sender_device_id;
2640
+ const identity = this._persisted.identityKeypair;
2641
+ const ephemeral = this._persisted.ephemeralKeypair;
2642
+ const sharedSecret = performX3DH({
2643
+ myIdentityPrivate: hexToBytes(identity.privateKey),
2644
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
2645
+ theirIdentityPublic: hexToBytes(otherMember.identityPublicKey),
2646
+ theirEphemeralPublic: hexToBytes(otherMember.ephemeralPublicKey ?? otherMember.identityPublicKey),
2647
+ isInitiator,
2648
+ });
2649
+ const peerIdPub = hexToBytes(otherMember.identityPublicKey);
2650
+ const newRatchet = isInitiator
2651
+ ? DoubleRatchet.initSender(sharedSecret, {
2652
+ publicKey: hexToBytes(identity.publicKey),
2653
+ privateKey: hexToBytes(identity.privateKey),
2654
+ keyType: "ed25519",
2655
+ }, peerIdPub)
2656
+ : DoubleRatchet.initReceiver(sharedSecret, {
2657
+ publicKey: hexToBytes(identity.publicKey),
2658
+ privateKey: hexToBytes(identity.privateKey),
2659
+ keyType: "ed25519",
2660
+ }, peerIdPub);
2661
+ session.ratchet = newRatchet;
2662
+ session.activated = false;
2663
+ this._persisted.sessions[convId] = {
2664
+ ownerDeviceId: session.ownerDeviceId,
2665
+ ratchetState: newRatchet.serialize(),
2666
+ activated: false,
2667
+ };
2668
+ await this._persistState();
2669
+ console.log(`[SecureChannel] Room ratchet re-initialized for conv ${convId.slice(0, 8)}...`);
2670
+ // Retry decrypt with fresh ratchet
2671
+ plaintext = session.ratchet.decrypt(encrypted);
2672
+ }
2673
+ catch (reinitErr) {
2674
+ console.error(`[SecureChannel] Room ratchet re-init failed for conv ${convId.slice(0, 8)}...:`, reinitErr);
2675
+ return;
2676
+ }
2677
+ }
2678
+ // Parse structured message envelope (same as 1:1 handler)
2679
+ let messageText;
2680
+ let messageType;
2681
+ try {
2682
+ const parsed = JSON.parse(plaintext);
2683
+ messageType = parsed.type || "message";
2684
+ messageText = parsed.text || plaintext;
2685
+ }
2686
+ catch {
2687
+ messageType = "message";
2688
+ messageText = plaintext;
2689
+ }
2690
+ // Activate session on first received message
2691
+ if (!session.activated) {
2692
+ session.activated = true;
2693
+ console.log(`[SecureChannel] Room session ${convId.slice(0, 8)}... activated by first message`);
2694
+ }
2695
+ // ACK if message_id present
2696
+ if (msgData.message_id) {
2697
+ this._sendAck(msgData.message_id);
2698
+ }
2699
+ // Advance sync cursor so the sync poll doesn't re-process this message
2700
+ if (msgData.created_at && this._persisted) {
2701
+ this._persisted.lastMessageTimestamp = msgData.created_at;
2702
+ }
2703
+ await this._persistState();
2704
+ const metadata = {
2705
+ messageId: msgData.message_id ?? "",
2706
+ conversationId: convId,
2707
+ timestamp: msgData.created_at ?? new Date().toISOString(),
2708
+ messageType: messageType,
2709
+ roomId: msgData.room_id,
2710
+ };
2711
+ this.emit("room_message", {
2712
+ roomId: msgData.room_id,
2713
+ senderDeviceId: msgData.sender_device_id,
2714
+ plaintext: messageText,
2715
+ messageType: messageType,
2716
+ timestamp: msgData.created_at ?? new Date().toISOString(),
2717
+ });
2718
+ Promise.resolve(this.config.onMessage?.(messageText, metadata)).catch((err) => {
2719
+ console.error("[SecureChannel] onMessage callback error:", err);
2720
+ });
2721
+ }
2722
+ /**
2723
+ * Find the pairwise conversation ID for a given sender in a room.
2724
+ */
2725
+ _findConversationForSender(senderDeviceId, roomId) {
2726
+ const room = this._persisted?.rooms?.[roomId];
2727
+ if (!room)
2728
+ return null;
2729
+ for (const convId of room.conversationIds) {
2730
+ const session = this._sessions.get(convId);
2731
+ if (session && session.ownerDeviceId === senderDeviceId) {
2732
+ return convId;
2733
+ }
2734
+ }
2735
+ return null;
2736
+ }
2737
+ /**
2738
+ * Distribute own sender key to all pairwise sessions in a room.
2739
+ * Each distribution is encrypted via the pairwise ratchet and sent
2740
+ * as a `sender_key_distribution` WS event.
2741
+ */
2742
+ async _distributeSenderKey(roomId) {
2743
+ if (!this._persisted?.rooms?.[roomId])
2744
+ return;
2745
+ const room = this._persisted.rooms[roomId];
2746
+ const chain = this._senderKeyChains.get(roomId);
2747
+ if (!chain || !this._deviceId)
2748
+ return;
2749
+ const dist = chain.getDistribution(this._deviceId);
2750
+ const distJson = JSON.stringify(dist);
2751
+ const alreadyDistributed = new Set(room.distributedTo ?? []);
2752
+ for (const convId of room.conversationIds) {
2753
+ const session = this._sessions.get(convId);
2754
+ if (!session || alreadyDistributed.has(session.ownerDeviceId))
2755
+ continue;
2756
+ // Encrypt distribution via pairwise ratchet
2757
+ const encrypted = session.ratchet.encrypt(distJson);
2758
+ const transport = encryptedMessageToTransport(encrypted);
2759
+ if (this._state === "ready" && this._ws) {
2760
+ this._ws.send(JSON.stringify({
2761
+ event: "sender_key_distribution",
2762
+ data: {
2763
+ room_id: roomId,
2764
+ recipient_device_id: session.ownerDeviceId,
2765
+ header_blob: transport.header_blob,
2766
+ ciphertext: transport.ciphertext,
2767
+ },
2768
+ }));
2769
+ alreadyDistributed.add(session.ownerDeviceId);
2770
+ }
2771
+ }
2772
+ room.distributedTo = [...alreadyDistributed];
2773
+ await this._persistState();
2774
+ }
2775
+ /**
2776
+ * Handle an incoming sender_key_distribution event.
2777
+ * Decrypts using the pairwise ratchet and ingests the peer's sender key.
2778
+ */
2779
+ async _handleSenderKeyDistribution(data) {
2780
+ if (data.sender_device_id === this._deviceId)
2781
+ return;
2782
+ // Find the pairwise conversation for this sender
2783
+ const convId = this._findConversationForSender(data.sender_device_id, data.room_id);
2784
+ if (!convId) {
2785
+ console.warn(`[SecureChannel] No pairwise conv for sender key from ${data.sender_device_id.slice(0, 8)}...`);
2786
+ return;
2787
+ }
2788
+ const session = this._sessions.get(convId);
2789
+ if (!session)
2790
+ return;
2791
+ // Decrypt via pairwise ratchet
2792
+ const encrypted = transportToEncryptedMessage({
2793
+ header_blob: data.header_blob,
2794
+ ciphertext: data.ciphertext,
2795
+ });
2796
+ let distJson;
2797
+ // Snapshot ratchet state before decrypt — DoubleRatchet.decrypt() mutates
2798
+ // internal state (DH ratchet step, chain key derivation) BEFORE AEAD
2799
+ // verification, so a failed decrypt permanently corrupts the ratchet.
2800
+ const ratchetSnapshot = session.ratchet.serialize();
2801
+ try {
2802
+ distJson = session.ratchet.decrypt(encrypted);
2803
+ }
2804
+ catch (err) {
2805
+ // Restore ratchet to pre-decrypt state
2806
+ session.ratchet = DoubleRatchet.deserialize(ratchetSnapshot);
2807
+ if (this._persisted?.sessions[convId]) {
2808
+ this._persisted.sessions[convId].ratchetState = ratchetSnapshot;
2809
+ }
2810
+ console.warn(`[SecureChannel] Failed to decrypt sender key distribution from ${data.sender_device_id.slice(0, 8)}... (ratchet restored):`, err);
2811
+ return;
2812
+ }
2813
+ // Parse and ingest
2814
+ let dist;
2815
+ try {
2816
+ dist = JSON.parse(distJson);
2817
+ }
2818
+ catch {
2819
+ console.warn("[SecureChannel] Corrupt sender key distribution JSON");
2820
+ return;
2821
+ }
2822
+ let peerState = this._senderKeyStates.get(data.room_id);
2823
+ if (!peerState) {
2824
+ peerState = SenderKeyState.create();
2825
+ this._senderKeyStates.set(data.room_id, peerState);
2826
+ }
2827
+ peerState.addDistribution(dist);
2828
+ // Check if all members have distributed — if so, enable sender_key mode
2829
+ const room = this._persisted?.rooms?.[data.room_id];
2830
+ if (room) {
2831
+ room.peerSenderKeyState = peerState.serialize();
2832
+ const otherMembers = room.members.filter((m) => m.deviceId !== this._deviceId);
2833
+ const allHaveKeys = otherMembers.every((m) => peerState.hasDistribution(m.deviceId));
2834
+ if (allHaveKeys && otherMembers.length > 0) {
2835
+ room.encryptionMode = "sender_key";
2836
+ console.log(`[SecureChannel] Room ${data.room_id.slice(0, 8)}... upgraded to sender_key mode ` +
2837
+ `(${otherMembers.length} peers)`);
2838
+ }
2839
+ }
2840
+ // Activate session on first received message
2841
+ if (!session.activated) {
2842
+ session.activated = true;
2843
+ }
2844
+ await this._persistState();
2845
+ // Reciprocate: distribute our own key to this sender if we haven't yet
2846
+ if (room && !(room.distributedTo ?? []).includes(data.sender_device_id)) {
2847
+ this._distributeSenderKey(data.room_id).catch(() => { });
2848
+ }
2849
+ }
2850
+ /**
2851
+ * Handle an incoming room_message_sk event (Sender Key encrypted message).
2852
+ */
2853
+ async _handleRoomMessageSK(msgData) {
2854
+ if (msgData.sender_device_id === this._deviceId)
2855
+ return;
2856
+ // Track room context so HTTP /send and OpenClaw tools auto-route to this room
2857
+ this._lastInboundRoomId = msgData.room_id;
2858
+ const peerState = this._senderKeyStates.get(msgData.room_id);
2859
+ if (!peerState) {
2860
+ console.warn(`[SecureChannel] No sender key state for room ${msgData.room_id.slice(0, 8)}...`);
2861
+ return;
2862
+ }
2863
+ const skMsg = {
2864
+ iteration: msgData.iteration,
2865
+ generationId: msgData.generation_id,
2866
+ nonce: msgData.nonce,
2867
+ ciphertext: msgData.ciphertext,
2868
+ signature: msgData.signature,
2869
+ };
2870
+ let plaintext;
2871
+ try {
2872
+ plaintext = peerState.decrypt(msgData.sender_device_id, skMsg);
2873
+ }
2874
+ catch (err) {
2875
+ console.warn(`[SecureChannel] SK decrypt failed from ${msgData.sender_device_id.slice(0, 8)}...:`, err);
2876
+ return;
2877
+ }
2878
+ // Persist updated state
2879
+ const room = this._persisted?.rooms?.[msgData.room_id];
2880
+ if (room) {
2881
+ room.peerSenderKeyState = peerState.serialize();
2882
+ }
2883
+ // Parse structured message envelope
2884
+ let messageText;
2885
+ let messageType;
2886
+ try {
2887
+ const parsed = JSON.parse(plaintext);
2888
+ messageType = parsed.type || "message";
2889
+ messageText = parsed.text || plaintext;
2890
+ }
2891
+ catch {
2892
+ messageType = "message";
2893
+ messageText = plaintext;
2894
+ }
2895
+ if (msgData.message_id) {
2896
+ this._sendAck(msgData.message_id);
2897
+ }
2898
+ // Advance sync cursor so the sync poll doesn't re-process this message
2899
+ if (msgData.created_at && this._persisted) {
2900
+ this._persisted.lastMessageTimestamp = msgData.created_at;
2901
+ }
2902
+ await this._persistState();
2903
+ const metadata = {
2904
+ messageId: msgData.message_id ?? "",
2905
+ conversationId: "",
2906
+ timestamp: msgData.created_at ?? new Date().toISOString(),
2907
+ messageType: messageType,
2908
+ roomId: msgData.room_id,
2909
+ };
2910
+ this.emit("room_message", {
2911
+ roomId: msgData.room_id,
2912
+ senderDeviceId: msgData.sender_device_id,
2913
+ plaintext: messageText,
2914
+ messageType: messageType,
2915
+ timestamp: msgData.created_at ?? new Date().toISOString(),
2916
+ });
2917
+ Promise.resolve(this.config.onMessage?.(messageText, metadata)).catch((err) => {
2918
+ console.error("[SecureChannel] onMessage callback error:", err);
2919
+ });
2920
+ }
2921
+ /**
2922
+ * Sync missed messages across ALL sessions.
2923
+ * For each conversation, fetches messages since last sync and decrypts.
2924
+ */
2925
+ /**
2926
+ * Paginated sync: fetch missed messages in pages of 200, up to 5 pages (1000 messages).
2927
+ * Tracks message IDs in _syncMessageIds to prevent duplicate processing from concurrent WS messages.
2928
+ */
2929
+ async _syncMissedMessages() {
2930
+ if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt)
2931
+ return;
2932
+ this._syncMessageIds = new Set();
2933
+ const MAX_PAGES = 5;
2934
+ const PAGE_SIZE = 200;
2935
+ let since = this._persisted.lastMessageTimestamp;
2936
+ let totalProcessed = 0;
2937
+ try {
2938
+ for (let page = 0; page < MAX_PAGES; page++) {
2939
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=${PAGE_SIZE}`;
2940
+ const res = await fetch(url, {
2941
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
2942
+ });
2943
+ if (!res.ok)
2944
+ break;
2945
+ const messages = await res.json();
2946
+ if (messages.length === 0)
2947
+ break;
2948
+ for (const msg of messages) {
2949
+ if (msg.sender_device_id === this._deviceId)
2950
+ continue;
2951
+ // Dedup: skip if already processed
2952
+ if (this._syncMessageIds.has(msg.id))
2953
+ continue;
2954
+ this._syncMessageIds.add(msg.id);
2955
+ // Route room conversations through _handleRoomMessage so roomId
2956
+ // is set in metadata (agent replies to room, not 1:1).
2957
+ // Prefer server-provided room_id, fall back to local room state.
2958
+ let roomId = msg.room_id;
2959
+ if (!roomId && this._persisted?.rooms) {
2960
+ for (const room of Object.values(this._persisted.rooms)) {
2961
+ if (room.conversationIds?.includes(msg.conversation_id)) {
2962
+ roomId = room.roomId;
2963
+ break;
2964
+ }
2965
+ }
2966
+ }
2967
+ if (roomId) {
2968
+ try {
2969
+ await this._handleRoomMessage({
2970
+ room_id: roomId,
2971
+ sender_device_id: msg.sender_device_id,
2972
+ conversation_id: msg.conversation_id,
2973
+ header_blob: msg.header_blob,
2974
+ ciphertext: msg.ciphertext,
2975
+ message_id: msg.id,
2976
+ created_at: msg.created_at,
2977
+ });
2978
+ }
2979
+ catch (roomErr) {
2980
+ console.warn(`[SecureChannel] Sync room message failed for ${msg.conversation_id.slice(0, 8)}...:`, roomErr);
2981
+ }
2982
+ this._persisted.lastMessageTimestamp = msg.created_at;
2983
+ since = msg.created_at;
2984
+ continue;
2985
+ }
2986
+ const session = this._sessions.get(msg.conversation_id);
2987
+ if (!session) {
2988
+ // No session during sync — skip silently. Resync only triggers
2989
+ // on real-time messages when the owner's browser is connected.
2990
+ console.warn(`[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`);
2991
+ continue;
2992
+ }
2993
+ try {
2994
+ const encrypted = transportToEncryptedMessage({
2995
+ header_blob: msg.header_blob,
2996
+ ciphertext: msg.ciphertext,
2997
+ });
2998
+ const ratchetSnapshot = session.ratchet.serialize();
2999
+ let plaintext;
3000
+ try {
3001
+ plaintext = session.ratchet.decrypt(encrypted);
3002
+ }
3003
+ catch (decryptErr) {
3004
+ console.error(`[SecureChannel] Sync decrypt failed for ${msg.conversation_id.slice(0, 8)}... — restoring ratchet:`, decryptErr);
3005
+ try {
3006
+ session.ratchet = DoubleRatchet.deserialize(ratchetSnapshot);
3007
+ }
3008
+ catch { }
3009
+ // Advance past the failing message to prevent infinite retry loop.
3010
+ this._persisted.lastMessageTimestamp = msg.created_at;
3011
+ since = msg.created_at;
3012
+ continue;
3013
+ }
3014
+ this._sendAck(msg.id);
3015
+ if (!session.activated) {
3016
+ session.activated = true;
3017
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
3018
+ }
3019
+ let messageText;
3020
+ let messageType;
3021
+ try {
3022
+ const parsed = JSON.parse(plaintext);
3023
+ messageType = parsed.type || "message";
3024
+ messageText = parsed.text || plaintext;
3025
+ }
3026
+ catch {
3027
+ messageType = "message";
3028
+ messageText = plaintext;
3029
+ }
3030
+ if (messageType === "message") {
3031
+ const topicId = msg.topic_id;
3032
+ this._appendHistory("owner", messageText, topicId);
3033
+ const metadata = {
3034
+ messageId: msg.id,
3035
+ conversationId: msg.conversation_id,
3036
+ timestamp: msg.created_at,
3037
+ topicId,
3038
+ };
3039
+ this.emit("message", messageText, metadata);
3040
+ Promise.resolve(this.config.onMessage?.(messageText, metadata)).catch((err) => {
3041
+ console.error("[SecureChannel] onMessage callback error:", err);
3042
+ });
3043
+ }
3044
+ this._persisted.lastMessageTimestamp = msg.created_at;
3045
+ since = msg.created_at;
3046
+ totalProcessed++;
3047
+ }
3048
+ catch (err) {
3049
+ console.warn(`[SecureChannel] Sync failed for msg ${msg.id.slice(0, 8)}... in conv ${msg.conversation_id.slice(0, 8)}...: ${String(err)}`);
3050
+ // Advance past the failing message to prevent infinite retry loop.
3051
+ // The message is lost but the sync cursor moves forward.
3052
+ this._persisted.lastMessageTimestamp = msg.created_at;
3053
+ since = msg.created_at;
3054
+ continue;
3055
+ }
3056
+ }
3057
+ await this._persistState();
3058
+ // If we got fewer than PAGE_SIZE, we've caught up
3059
+ if (messages.length < PAGE_SIZE)
3060
+ break;
3061
+ }
3062
+ if (totalProcessed > 0) {
3063
+ console.log(`[SecureChannel] Synced ${totalProcessed} missed messages`);
3064
+ }
3065
+ }
3066
+ catch {
3067
+ // Network error -- non-critical, will get messages via WS
3068
+ }
3069
+ this._syncMessageIds = null;
3070
+ }
3071
+ _sendAck(messageId) {
3072
+ this._pendingAcks.push(messageId);
3073
+ if (this._ackTimer)
3074
+ clearTimeout(this._ackTimer);
3075
+ this._ackTimer = setTimeout(() => this._flushAcks(), 500);
3076
+ }
3077
+ _flushAcks() {
3078
+ if (this._pendingAcks.length === 0 || !this._ws)
3079
+ return;
3080
+ const batch = this._pendingAcks.splice(0, 50);
3081
+ this._ws.send(JSON.stringify({ event: "ack", data: { message_ids: batch } }));
3082
+ }
3083
+ async _flushOutboundQueue() {
3084
+ const queue = this._persisted?.outboundQueue;
3085
+ if (!queue || queue.length === 0 || !this._ws)
3086
+ return;
3087
+ console.log(`[SecureChannel] Flushing ${queue.length} queued outbound messages`);
3088
+ const messages = queue.splice(0); // Take all, clear queue
3089
+ for (const msg of messages) {
3090
+ try {
3091
+ this._ws.send(JSON.stringify({
3092
+ event: "message",
3093
+ data: {
3094
+ conversation_id: msg.convId,
3095
+ header_blob: msg.headerBlob,
3096
+ ciphertext: msg.ciphertext,
3097
+ message_group_id: msg.messageGroupId,
3098
+ topic_id: msg.topicId,
3099
+ },
3100
+ }));
3101
+ }
3102
+ catch (err) {
3103
+ // Re-queue failed messages
3104
+ if (!this._persisted.outboundQueue) {
3105
+ this._persisted.outboundQueue = [];
3106
+ }
3107
+ this._persisted.outboundQueue.push(msg);
3108
+ console.warn(`[SecureChannel] Failed to flush message, re-queued: ${err}`);
3109
+ break; // Stop flushing on first failure
3110
+ }
3111
+ }
3112
+ await this._persistState();
3113
+ }
3114
+ _startPing(ws) {
3115
+ this._stopPing(); // Clear any existing timers
3116
+ this._lastServerMessage = Date.now();
3117
+ this._pingTimer = setInterval(() => {
3118
+ if (ws.readyState !== WebSocket.OPEN)
3119
+ return;
3120
+ const silence = Date.now() - this._lastServerMessage;
3121
+ if (silence > SecureChannel.SILENCE_TIMEOUT_MS) {
3122
+ console.log(`[SecureChannel] No server data for ${Math.round(silence / 1000)}s — reconnecting stale WebSocket`);
3123
+ ws.terminate(); // Forces close → _scheduleReconnect fires
3124
+ }
3125
+ }, SecureChannel.PING_INTERVAL_MS);
3126
+ }
3127
+ _stopPing() {
3128
+ if (this._pingTimer) {
3129
+ clearInterval(this._pingTimer);
3130
+ this._pingTimer = null;
3131
+ }
3132
+ }
3133
+ _startWakeDetector() {
3134
+ this._stopWakeDetector();
3135
+ this._lastWakeTick = Date.now();
3136
+ this._wakeDetectorTimer = setInterval(() => {
3137
+ const gap = Date.now() - this._lastWakeTick;
3138
+ this._lastWakeTick = Date.now();
3139
+ if (gap > 120_000 && this._ws) {
3140
+ console.log(`[SecureChannel] System wake detected (${Math.round(gap / 1000)}s gap) — forcing reconnect`);
3141
+ this._ws.terminate();
3142
+ }
3143
+ }, 10_000);
3144
+ }
3145
+ _stopWakeDetector() {
3146
+ if (this._wakeDetectorTimer) {
3147
+ clearInterval(this._wakeDetectorTimer);
3148
+ this._wakeDetectorTimer = null;
3149
+ }
3150
+ }
3151
+ _startPendingPoll() {
3152
+ this._stopPendingPoll();
3153
+ this._pendingPollTimer = setInterval(() => {
3154
+ this._checkPendingMessages();
3155
+ }, PENDING_POLL_INTERVAL_MS);
3156
+ }
3157
+ _stopPendingPoll() {
3158
+ if (this._pendingPollTimer) {
3159
+ clearInterval(this._pendingPollTimer);
3160
+ this._pendingPollTimer = null;
3161
+ }
3162
+ }
3163
+ async _checkPendingMessages() {
3164
+ // Don't poll while connecting (prevent hammering during reconnect)
3165
+ if (this._state === "connecting" || !this._deviceId || !this._deviceJwt)
3166
+ return;
3167
+ try {
3168
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/pending`;
3169
+ const resp = await fetch(url, {
3170
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
3171
+ signal: AbortSignal.timeout(10_000),
3172
+ });
3173
+ if (!resp.ok)
3174
+ return; // Silent fail — WS silence detector is backup
3175
+ const data = (await resp.json());
3176
+ if (data.pending > 0 && this._state !== "ready") {
3177
+ console.log(`[SecureChannel] Poll detected ${data.pending} pending messages — forcing reconnect`);
3178
+ if (this._ws) {
3179
+ this._ws.terminate();
3180
+ }
3181
+ else {
3182
+ this._scheduleReconnect();
3183
+ }
3184
+ }
3185
+ else if (data.pending > 0 && this._state === "ready") {
3186
+ // WS claims ready but messages are pending — possible half-open connection
3187
+ const silence = Date.now() - this._lastServerMessage;
3188
+ if (silence > 180_000) {
3189
+ console.log(`[SecureChannel] Poll: ${data.pending} pending + ${Math.round(silence / 1000)}s silence — forcing reconnect`);
3190
+ if (this._ws)
3191
+ this._ws.terminate();
3192
+ }
3193
+ }
3194
+ }
3195
+ catch {
3196
+ // Network error on poll — expected when offline, ignore silently
3197
+ }
3198
+ }
3199
+ _scheduleReconnect() {
3200
+ if (this._stopped)
3201
+ return;
3202
+ if (this._reconnectTimer)
3203
+ return; // Already scheduled
3204
+ // Detect rapid-disconnect cascade (e.g. two processes as same device).
3205
+ // If WS opened recently (< 10s) and we're disconnecting, count it.
3206
+ const sinceOpen = Date.now() - this._lastWsOpenTime;
3207
+ if (this._lastWsOpenTime > 0 && sinceOpen < 10_000) {
3208
+ this._rapidDisconnects++;
3209
+ }
3210
+ else {
3211
+ this._rapidDisconnects = 0;
3212
+ }
3213
+ if (this._rapidDisconnects >= 5) {
3214
+ console.error(`[SecureChannel] Detected rapid connect/disconnect loop (${this._rapidDisconnects} times in <10s each).`);
3215
+ console.error(`[SecureChannel] This usually means another process is already connected as this device.`);
3216
+ console.error(`[SecureChannel] Stopping reconnection. Check for duplicate gateway processes or stale CLI sessions.`);
3217
+ this._setState("error");
3218
+ this.emit("error", new Error("Reconnect loop detected — another process may be connected as this device"));
3219
+ return;
3220
+ }
3221
+ const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, this._reconnectAttempt), RECONNECT_MAX_MS);
3222
+ this._reconnectAttempt++;
3223
+ console.log(`[SecureChannel] Scheduling reconnect in ${delay}ms (attempt ${this._reconnectAttempt})`);
3224
+ this._reconnectTimer = setTimeout(() => {
3225
+ this._reconnectTimer = null; // Clear BEFORE calling _connect so future disconnects can schedule new reconnects
3226
+ if (!this._stopped) {
3227
+ this._connect();
3228
+ }
3229
+ }, delay);
3230
+ }
3231
+ _setState(newState) {
3232
+ if (this._state === newState)
3233
+ return;
3234
+ this._state = newState;
3235
+ this.emit("state", newState);
3236
+ this.config.onStateChange?.(newState);
3237
+ // Start/stop polling fallback based on connection state
3238
+ if (newState === "disconnected" && !this._pollFallbackTimer && this._deviceJwt) {
3239
+ this._startPollFallback();
3240
+ }
3241
+ if (newState === "ready" || newState === "error" || this._stopped) {
3242
+ this._stopPollFallback();
3243
+ }
3244
+ }
3245
+ _startPollFallback() {
3246
+ this._stopPollFallback();
3247
+ console.log("[SecureChannel] Starting HTTP poll fallback (WS is down)");
3248
+ let interval = SecureChannel.POLL_FALLBACK_INTERVAL_MS;
3249
+ const poll = async () => {
3250
+ if (this._state === "ready" || this._stopped) {
3251
+ this._stopPollFallback();
3252
+ return;
3253
+ }
3254
+ try {
3255
+ const since = this._persisted?.lastMessageTimestamp;
3256
+ if (!since || !this._deviceJwt)
3257
+ return;
3258
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=200`;
3259
+ const res = await fetch(url, {
3260
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
3261
+ });
3262
+ if (!res.ok)
3263
+ return;
3264
+ const messages = await res.json();
3265
+ let foundMessages = false;
3266
+ for (const msg of messages) {
3267
+ if (msg.sender_device_id === this._deviceId)
3268
+ continue;
3269
+ const session = this._sessions.get(msg.conversation_id);
3270
+ if (!session)
3271
+ continue;
3272
+ try {
3273
+ const encrypted = transportToEncryptedMessage({
3274
+ header_blob: msg.header_blob,
3275
+ ciphertext: msg.ciphertext,
3276
+ });
3277
+ const ratchetSnapshot = session.ratchet.serialize();
3278
+ let plaintext;
3279
+ try {
3280
+ plaintext = session.ratchet.decrypt(encrypted);
3281
+ }
3282
+ catch (decryptErr) {
3283
+ console.error(`[SecureChannel] Room sync decrypt failed for ${msg.conversation_id.slice(0, 8)}... — restoring ratchet:`, decryptErr);
3284
+ try {
3285
+ session.ratchet = DoubleRatchet.deserialize(ratchetSnapshot);
3286
+ }
3287
+ catch { }
3288
+ continue;
3289
+ }
3290
+ if (!session.activated) {
3291
+ session.activated = true;
3292
+ }
3293
+ let messageText;
3294
+ let messageType;
3295
+ try {
3296
+ const parsed = JSON.parse(plaintext);
3297
+ messageType = parsed.type || "message";
3298
+ messageText = parsed.text || plaintext;
3299
+ }
3300
+ catch {
3301
+ messageType = "message";
3302
+ messageText = plaintext;
3303
+ }
3304
+ if (messageType === "message") {
3305
+ const topicId = msg.topic_id;
3306
+ this._appendHistory("owner", messageText, topicId);
3307
+ const metadata = {
3308
+ messageId: msg.id,
3309
+ conversationId: msg.conversation_id,
3310
+ timestamp: msg.created_at,
3311
+ topicId,
3312
+ };
3313
+ this.emit("message", messageText, metadata);
3314
+ Promise.resolve(this.config.onMessage?.(messageText, metadata)).catch((err) => {
3315
+ console.error("[SecureChannel] onMessage callback error:", err);
3316
+ });
3317
+ foundMessages = true;
3318
+ }
3319
+ this._persisted.lastMessageTimestamp = msg.created_at;
3320
+ }
3321
+ catch (err) {
3322
+ this.emit("error", err);
3323
+ break;
3324
+ }
3325
+ }
3326
+ if (messages.length > 0) {
3327
+ await this._persistState();
3328
+ }
3329
+ // Adaptive rate: faster when active, slower when idle
3330
+ const newInterval = foundMessages
3331
+ ? SecureChannel.POLL_FALLBACK_INTERVAL_MS
3332
+ : SecureChannel.POLL_FALLBACK_IDLE_MS;
3333
+ if (newInterval !== interval) {
3334
+ interval = newInterval;
3335
+ // Restart timer with new interval
3336
+ if (this._pollFallbackTimer) {
3337
+ clearInterval(this._pollFallbackTimer);
3338
+ this._pollFallbackTimer = setInterval(poll, interval);
3339
+ }
3340
+ }
3341
+ }
3342
+ catch {
3343
+ // Network error — try again next tick
3344
+ }
3345
+ };
3346
+ // Run first poll after a short delay, then on interval
3347
+ setTimeout(poll, 1_000);
3348
+ this._pollFallbackTimer = setInterval(poll, interval);
3349
+ }
3350
+ _stopPollFallback() {
3351
+ if (this._pollFallbackTimer) {
3352
+ clearInterval(this._pollFallbackTimer);
3353
+ this._pollFallbackTimer = null;
3354
+ console.log("[SecureChannel] Stopped HTTP poll fallback");
3355
+ }
3356
+ }
3357
+ _handleError(err) {
3358
+ this._setState("error");
3359
+ this.emit("error", err);
3360
+ }
3361
+ /**
3362
+ * Persist all ratchet session states to disk.
3363
+ * Syncs live ratchet states back into the persisted sessions map.
3364
+ */
3365
+ async _persistState() {
3366
+ if (!this._persisted)
3367
+ return;
3368
+ const hasOwnerSessions = this._sessions.size > 0;
3369
+ const hasA2AChannels = !!this._persisted.a2aChannels && Object.keys(this._persisted.a2aChannels).length > 0;
3370
+ if (!hasOwnerSessions && !hasA2AChannels)
3371
+ return;
3372
+ // Persist sticky room context for proactive send routing across restarts
3373
+ this._persisted.lastInboundRoomId = this._lastInboundRoomId;
3374
+ // Sync all live owner-agent ratchet states back to persisted
3375
+ for (const [convId, session] of this._sessions) {
3376
+ this._persisted.sessions[convId] = {
3377
+ ownerDeviceId: session.ownerDeviceId,
3378
+ ratchetState: session.ratchet.serialize(),
3379
+ activated: session.activated,
3380
+ };
3381
+ }
3382
+ await saveState(this.config.dataDir, this._persisted);
3383
+ await backupState(this.config.dataDir);
3384
+ this._scheduleServerBackup();
3385
+ }
3386
+ /**
3387
+ * Debounced server backup upload (60s after last state change).
3388
+ * Only runs when backupCode is configured.
3389
+ */
3390
+ _scheduleServerBackup() {
3391
+ if (!this.config.backupCode || !this._persisted || !this._deviceJwt)
3392
+ return;
3393
+ if (this._serverBackupTimer)
3394
+ clearTimeout(this._serverBackupTimer);
3395
+ this._serverBackupTimer = setTimeout(async () => {
3396
+ if (this._serverBackupRunning || !this._persisted || !this._deviceJwt)
3397
+ return;
3398
+ this._serverBackupRunning = true;
3399
+ try {
3400
+ await uploadBackupToServer(this._persisted, this.config.backupCode, this.config.apiUrl, this._deviceJwt);
3401
+ }
3402
+ catch (err) {
3403
+ console.warn("[SecureChannel] Server backup upload failed:", err);
3404
+ }
3405
+ finally {
3406
+ this._serverBackupRunning = false;
3407
+ }
3408
+ }, 60_000);
3409
+ }
3410
+ }
3411
+ //# sourceMappingURL=channel.js.map