@agentvault/agentvault 0.9.5 → 0.9.7

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.
@@ -0,0 +1,2257 @@
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, } from "@agentvault/crypto";
10
+ import { hexToBytes, bytesToHex, base64ToBytes, bytesToBase64, encryptedMessageToTransport, transportToEncryptedMessage, } from "./crypto-helpers.js";
11
+ import { saveState, loadState, clearState } 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
+ _pingTimer = null;
59
+ _lastServerMessage = 0;
60
+ _pendingAcks = [];
61
+ _ackTimer = null;
62
+ _stopped = false;
63
+ _persisted = null;
64
+ _httpServer = null;
65
+ _pollFallbackTimer = null;
66
+ _heartbeatTimer = null;
67
+ _heartbeatCallback = null;
68
+ _heartbeatIntervalSeconds = 0;
69
+ _wakeDetectorTimer = null;
70
+ _lastWakeTick = Date.now();
71
+ _pendingPollTimer = null;
72
+ _syncMessageIds = null;
73
+ /** Queued A2A messages for responder channels not yet activated (no first initiator message received). */
74
+ _a2aPendingQueue = {};
75
+ _scanEngine = null;
76
+ _scanRuleSetVersion = 0;
77
+ // Liveness detection: server sends app-level {"event":"ping"} every 30s.
78
+ // We check every 30s; if no data received in 90s (3 missed pings), connection is dead.
79
+ static PING_INTERVAL_MS = 30_000;
80
+ static SILENCE_TIMEOUT_MS = 90_000;
81
+ static POLL_FALLBACK_INTERVAL_MS = 30_000; // 30s when messages found
82
+ static POLL_FALLBACK_IDLE_MS = 60_000; // 60s when idle
83
+ constructor(config) {
84
+ super();
85
+ this.config = config;
86
+ }
87
+ get state() {
88
+ return this._state;
89
+ }
90
+ get deviceId() {
91
+ return this._deviceId;
92
+ }
93
+ get fingerprint() {
94
+ return this._fingerprint;
95
+ }
96
+ /** Returns the primary conversation ID (backward-compatible). */
97
+ get conversationId() {
98
+ return this._primaryConversationId || null;
99
+ }
100
+ /** Returns all active conversation IDs. */
101
+ get conversationIds() {
102
+ return Array.from(this._sessions.keys());
103
+ }
104
+ /** Returns the number of active sessions. */
105
+ get sessionCount() {
106
+ return this._sessions.size;
107
+ }
108
+ async start() {
109
+ this._stopped = false;
110
+ await sodium.ready;
111
+ // Check for persisted state (may be legacy or new format)
112
+ const raw = await loadState(this.config.dataDir);
113
+ if (raw) {
114
+ this._persisted = migratePersistedState(raw);
115
+ if (!this._persisted.messageHistory) {
116
+ this._persisted.messageHistory = [];
117
+ }
118
+ this._deviceId = this._persisted.deviceId;
119
+ this._deviceJwt = this._persisted.deviceJwt;
120
+ this._primaryConversationId = this._persisted.primaryConversationId;
121
+ this._fingerprint = this._persisted.fingerprint;
122
+ // Restore all ratchet sessions
123
+ for (const [convId, sessionData] of Object.entries(this._persisted.sessions)) {
124
+ if (sessionData.ratchetState) {
125
+ const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
126
+ this._sessions.set(convId, {
127
+ ownerDeviceId: sessionData.ownerDeviceId,
128
+ ratchet,
129
+ activated: sessionData.activated ?? false,
130
+ });
131
+ }
132
+ }
133
+ this._connect();
134
+ return;
135
+ }
136
+ // Full lifecycle: enroll -> poll -> activate -> connect
137
+ await this._enroll();
138
+ }
139
+ /**
140
+ * Fetch scan rules from the server and load them into the ScanEngine.
141
+ */
142
+ async _fetchScanRules() {
143
+ if (!this._scanEngine)
144
+ return;
145
+ try {
146
+ const resp = await fetch(`${this.config.apiUrl}/api/v1/scan-rules`, {
147
+ headers: { Authorization: `Bearer ${this._persisted?.deviceJwt}` },
148
+ });
149
+ if (!resp.ok)
150
+ return;
151
+ const data = await resp.json();
152
+ if (data.rule_set_version !== this._scanRuleSetVersion) {
153
+ this._scanEngine.loadRules(data.rules);
154
+ this._scanRuleSetVersion = data.rule_set_version;
155
+ }
156
+ }
157
+ catch (err) {
158
+ console.error("[SecureChannel] Failed to fetch scan rules:", err);
159
+ }
160
+ }
161
+ /**
162
+ * Append a message to persistent history for cross-device replay.
163
+ */
164
+ _appendHistory(sender, text, topicId) {
165
+ if (!this._persisted)
166
+ return;
167
+ if (!this._persisted.messageHistory) {
168
+ this._persisted.messageHistory = [];
169
+ }
170
+ const maxSize = this.config.maxHistorySize ?? 500;
171
+ const entry = {
172
+ sender,
173
+ text,
174
+ ts: new Date().toISOString(),
175
+ };
176
+ if (topicId) {
177
+ entry.topicId = topicId;
178
+ }
179
+ this._persisted.messageHistory.push(entry);
180
+ // Evict oldest entries if over limit
181
+ if (this._persisted.messageHistory.length > maxSize) {
182
+ this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
183
+ }
184
+ }
185
+ /**
186
+ * Encrypt and send a message to ALL owner devices (fanout).
187
+ * Each session gets the same plaintext encrypted independently.
188
+ */
189
+ async send(plaintext, options) {
190
+ // Permanent failures — still throw
191
+ if (this._state === "error" || this._state === "idle") {
192
+ throw new Error("Channel is not ready");
193
+ }
194
+ if (this._sessions.size === 0) {
195
+ throw new Error("No active sessions");
196
+ }
197
+ const topicId = options?.topicId ?? this._persisted?.defaultTopicId;
198
+ const messageType = options?.messageType ?? "text";
199
+ const priority = options?.priority ?? "normal";
200
+ const parentSpanId = options?.parentSpanId;
201
+ const envelopeMetadata = options?.metadata;
202
+ // Outbound scan — before encryption
203
+ let scanStatus;
204
+ if (this._scanEngine) {
205
+ const scanResult = this._scanEngine.scanOutbound(plaintext);
206
+ if (scanResult.status === "blocked") {
207
+ this.emit("scan_blocked", { direction: "outbound", violations: scanResult.violations });
208
+ throw new Error(`Message blocked by scan rule: ${scanResult.violations[0]?.rule_name}`);
209
+ }
210
+ scanStatus = scanResult.status; // "clean" or "flagged"
211
+ }
212
+ // Store agent message in history for cross-device replay
213
+ this._appendHistory("agent", plaintext, topicId);
214
+ const messageGroupId = randomUUID();
215
+ for (const [convId, session] of this._sessions) {
216
+ if (!session.activated)
217
+ continue;
218
+ const encrypted = session.ratchet.encrypt(plaintext);
219
+ const transport = encryptedMessageToTransport(encrypted);
220
+ const msg = {
221
+ convId,
222
+ headerBlob: transport.header_blob,
223
+ ciphertext: transport.ciphertext,
224
+ messageGroupId,
225
+ topicId,
226
+ };
227
+ if (this._state === "ready" && this._ws) {
228
+ // Send immediately
229
+ const payload = {
230
+ conversation_id: msg.convId,
231
+ header_blob: msg.headerBlob,
232
+ ciphertext: msg.ciphertext,
233
+ message_group_id: msg.messageGroupId,
234
+ topic_id: msg.topicId,
235
+ message_type: messageType,
236
+ priority: priority,
237
+ parent_span_id: parentSpanId,
238
+ metadata: scanStatus
239
+ ? { ...(envelopeMetadata ?? {}), scan_status: scanStatus }
240
+ : envelopeMetadata,
241
+ };
242
+ if (this._persisted?.hubAddress) {
243
+ payload.hub_address = this._persisted.hubAddress;
244
+ }
245
+ this._ws.send(JSON.stringify({
246
+ event: "message",
247
+ data: payload,
248
+ }));
249
+ }
250
+ else {
251
+ // Queue for later
252
+ if (!this._persisted.outboundQueue) {
253
+ this._persisted.outboundQueue = [];
254
+ }
255
+ if (this._persisted.outboundQueue.length >= 50) {
256
+ this._persisted.outboundQueue.shift(); // Drop oldest
257
+ console.warn("[SecureChannel] Outbound queue full, dropping oldest message");
258
+ }
259
+ this._persisted.outboundQueue.push(msg);
260
+ console.log(`[SecureChannel] Message queued (state=${this._state}, queue=${this._persisted.outboundQueue.length})`);
261
+ }
262
+ }
263
+ // Persist all ratchet states after encrypt
264
+ await this._persistState();
265
+ }
266
+ /**
267
+ * Send a typing indicator to all owner devices.
268
+ * Ephemeral (unencrypted metadata), no ratchet advancement.
269
+ */
270
+ sendTyping() {
271
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN)
272
+ return;
273
+ for (const convId of this._sessions.keys()) {
274
+ this._ws.send(JSON.stringify({
275
+ event: "typing",
276
+ data: { conversation_id: convId },
277
+ }));
278
+ }
279
+ }
280
+ /**
281
+ * Send a decision request to the owner.
282
+ * Builds a structured envelope with decision metadata and sends it
283
+ * as a high-priority message. Returns the generated decision_id.
284
+ */
285
+ async sendDecisionRequest(request) {
286
+ const decision_id = `dec_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
287
+ const payload = JSON.stringify({
288
+ type: "message",
289
+ text: `\u{1F4CB} ${request.title}`,
290
+ decision: {
291
+ decision_id,
292
+ ...request,
293
+ },
294
+ });
295
+ await this.send(payload, {
296
+ messageType: "decision_request",
297
+ priority: "high",
298
+ metadata: {
299
+ decision_id,
300
+ title: request.title,
301
+ description: request.description,
302
+ options: request.options,
303
+ context_refs: request.context_refs,
304
+ deadline: request.deadline,
305
+ auto_action: request.auto_action,
306
+ },
307
+ });
308
+ return decision_id;
309
+ }
310
+ /**
311
+ * Wait for a decision response matching the given decisionId.
312
+ * Listens on the "message" event for messages where
313
+ * metadata.messageType === "decision_response" and the parsed plaintext
314
+ * contains a matching decision.decision_id.
315
+ * Optional timeout rejects with an Error.
316
+ */
317
+ waitForDecision(decisionId, timeoutMs) {
318
+ return new Promise((resolve, reject) => {
319
+ let timer = null;
320
+ const handler = (plaintext, metadata) => {
321
+ if (metadata.messageType !== "decision_response")
322
+ return;
323
+ try {
324
+ const parsed = JSON.parse(plaintext);
325
+ if (parsed.decision?.decision_id === decisionId) {
326
+ if (timer)
327
+ clearTimeout(timer);
328
+ this.removeListener("message", handler);
329
+ resolve({
330
+ decision_id: parsed.decision.decision_id,
331
+ selected_option_id: parsed.decision.selected_option_id,
332
+ resolved_at: parsed.decision.resolved_at,
333
+ action: parsed.decision.action,
334
+ defer_until: parsed.decision.defer_until,
335
+ defer_reason: parsed.decision.defer_reason,
336
+ });
337
+ }
338
+ }
339
+ catch {
340
+ // Not valid JSON, ignore
341
+ }
342
+ };
343
+ this.on("message", handler);
344
+ if (timeoutMs !== undefined) {
345
+ timer = setTimeout(() => {
346
+ this.removeListener("message", handler);
347
+ reject(new Error(`Decision ${decisionId} timed out after ${timeoutMs}ms`));
348
+ }, timeoutMs);
349
+ }
350
+ });
351
+ }
352
+ // --- Multi-agent room methods ---
353
+ /**
354
+ * Join a room by performing X3DH key exchange with each member
355
+ * for the pairwise conversations involving this device.
356
+ */
357
+ async joinRoom(roomData) {
358
+ if (!this._persisted) {
359
+ throw new Error("Channel not initialized");
360
+ }
361
+ await sodium.ready;
362
+ const identity = this._persisted.identityKeypair;
363
+ const ephemeral = this._persisted.ephemeralKeypair;
364
+ const myDeviceId = this._deviceId;
365
+ const conversationIds = [];
366
+ for (const conv of roomData.conversations) {
367
+ // Only process conversations involving this device
368
+ if (conv.participantA !== myDeviceId && conv.participantB !== myDeviceId) {
369
+ continue;
370
+ }
371
+ const otherDeviceId = conv.participantA === myDeviceId ? conv.participantB : conv.participantA;
372
+ const otherMember = roomData.members.find((m) => m.deviceId === otherDeviceId);
373
+ if (!otherMember?.identityPublicKey) {
374
+ console.warn(`[SecureChannel] No public key for member ${otherDeviceId.slice(0, 8)}..., skipping`);
375
+ continue;
376
+ }
377
+ // Determine initiator: lexicographically smaller deviceId is the sender (initiator)
378
+ const isInitiator = myDeviceId < otherDeviceId;
379
+ const sharedSecret = performX3DH({
380
+ myIdentityPrivate: hexToBytes(identity.privateKey),
381
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
382
+ theirIdentityPublic: hexToBytes(otherMember.identityPublicKey),
383
+ theirEphemeralPublic: hexToBytes(otherMember.ephemeralPublicKey ?? otherMember.identityPublicKey),
384
+ isInitiator,
385
+ });
386
+ const ratchet = isInitiator
387
+ ? DoubleRatchet.initSender(sharedSecret, {
388
+ publicKey: hexToBytes(identity.publicKey),
389
+ privateKey: hexToBytes(identity.privateKey),
390
+ keyType: "ed25519",
391
+ })
392
+ : DoubleRatchet.initReceiver(sharedSecret, {
393
+ publicKey: hexToBytes(identity.publicKey),
394
+ privateKey: hexToBytes(identity.privateKey),
395
+ keyType: "ed25519",
396
+ });
397
+ this._sessions.set(conv.id, {
398
+ ownerDeviceId: otherDeviceId,
399
+ ratchet,
400
+ activated: isInitiator, // initiator can send immediately
401
+ });
402
+ // Persist session
403
+ this._persisted.sessions[conv.id] = {
404
+ ownerDeviceId: otherDeviceId,
405
+ ratchetState: ratchet.serialize(),
406
+ activated: isInitiator,
407
+ };
408
+ conversationIds.push(conv.id);
409
+ console.log(`[SecureChannel] Room session initialized: conv ${conv.id.slice(0, 8)}... ` +
410
+ `with ${otherDeviceId.slice(0, 8)}... (initiator=${isInitiator})`);
411
+ }
412
+ // Store room state
413
+ if (!this._persisted.rooms) {
414
+ this._persisted.rooms = {};
415
+ }
416
+ this._persisted.rooms[roomData.roomId] = {
417
+ roomId: roomData.roomId,
418
+ name: roomData.name,
419
+ conversationIds,
420
+ members: roomData.members,
421
+ };
422
+ await this._persistState();
423
+ this.emit("room_joined", { roomId: roomData.roomId, name: roomData.name });
424
+ }
425
+ /**
426
+ * Send an encrypted message to all members of a room.
427
+ * Each pairwise conversation gets the plaintext encrypted independently.
428
+ */
429
+ async sendToRoom(roomId, plaintext, opts) {
430
+ if (!this._persisted?.rooms?.[roomId]) {
431
+ throw new Error(`Room ${roomId} not found`);
432
+ }
433
+ const room = this._persisted.rooms[roomId];
434
+ const messageType = opts?.messageType ?? "text";
435
+ const recipients = [];
436
+ for (const convId of room.conversationIds) {
437
+ const session = this._sessions.get(convId);
438
+ if (!session) {
439
+ console.warn(`[SecureChannel] No session for room conv ${convId.slice(0, 8)}..., skipping`);
440
+ continue;
441
+ }
442
+ const encrypted = session.ratchet.encrypt(plaintext);
443
+ const transport = encryptedMessageToTransport(encrypted);
444
+ recipients.push({
445
+ device_id: session.ownerDeviceId,
446
+ header_blob: transport.header_blob,
447
+ ciphertext: transport.ciphertext,
448
+ });
449
+ }
450
+ if (recipients.length === 0) {
451
+ throw new Error("No active sessions in room");
452
+ }
453
+ if (this._state === "ready" && this._ws) {
454
+ this._ws.send(JSON.stringify({
455
+ event: "room_message",
456
+ room_id: roomId,
457
+ recipients,
458
+ message_type: messageType,
459
+ }));
460
+ }
461
+ else {
462
+ // HTTP fallback
463
+ try {
464
+ const res = await fetch(`${this.config.apiUrl}/api/v1/rooms/${roomId}/messages`, {
465
+ method: "POST",
466
+ headers: {
467
+ "Content-Type": "application/json",
468
+ Authorization: `Bearer ${this._deviceJwt}`,
469
+ },
470
+ body: JSON.stringify({ recipients, message_type: messageType }),
471
+ });
472
+ if (!res.ok) {
473
+ const detail = await res.text();
474
+ throw new Error(`Room message failed (${res.status}): ${detail}`);
475
+ }
476
+ }
477
+ catch (err) {
478
+ throw new Error(`Failed to send room message: ${err}`);
479
+ }
480
+ }
481
+ await this._persistState();
482
+ }
483
+ /**
484
+ * Leave a room: remove sessions and persisted room state.
485
+ */
486
+ async leaveRoom(roomId) {
487
+ if (!this._persisted?.rooms?.[roomId]) {
488
+ return; // Already left or never joined
489
+ }
490
+ const room = this._persisted.rooms[roomId];
491
+ // Remove sessions for this room's conversations
492
+ for (const convId of room.conversationIds) {
493
+ this._sessions.delete(convId);
494
+ delete this._persisted.sessions[convId];
495
+ }
496
+ // Remove room state
497
+ delete this._persisted.rooms[roomId];
498
+ await this._persistState();
499
+ this.emit("room_left", { roomId });
500
+ }
501
+ /**
502
+ * Return info for all joined rooms.
503
+ */
504
+ getRooms() {
505
+ if (!this._persisted?.rooms)
506
+ return [];
507
+ return Object.values(this._persisted.rooms).map((rs) => ({
508
+ roomId: rs.roomId,
509
+ name: rs.name,
510
+ members: rs.members,
511
+ conversationIds: rs.conversationIds,
512
+ }));
513
+ }
514
+ // --- Heartbeat and status methods ---
515
+ startHeartbeat(intervalSeconds, statusCallback) {
516
+ this.stopHeartbeat();
517
+ this._heartbeatCallback = statusCallback;
518
+ this._heartbeatIntervalSeconds = intervalSeconds;
519
+ this._sendHeartbeat();
520
+ this._heartbeatTimer = setInterval(() => {
521
+ this._sendHeartbeat();
522
+ }, intervalSeconds * 1000);
523
+ }
524
+ async stopHeartbeat() {
525
+ if (this._heartbeatTimer) {
526
+ clearInterval(this._heartbeatTimer);
527
+ this._heartbeatTimer = null;
528
+ }
529
+ if (this._heartbeatCallback && this._state === "ready") {
530
+ try {
531
+ await this.send(JSON.stringify({
532
+ agent_status: "shutting_down",
533
+ current_task: "",
534
+ timestamp: new Date().toISOString(),
535
+ }), {
536
+ messageType: "heartbeat",
537
+ metadata: { next_heartbeat_seconds: this._heartbeatIntervalSeconds },
538
+ });
539
+ }
540
+ catch { /* best-effort shutdown heartbeat */ }
541
+ }
542
+ this._heartbeatCallback = null;
543
+ }
544
+ async sendStatusAlert(alert) {
545
+ const priority = alert.severity === "error" || alert.severity === "critical"
546
+ ? "high" : "normal";
547
+ const envelope = {
548
+ title: alert.title,
549
+ message: alert.message,
550
+ severity: alert.severity,
551
+ timestamp: new Date().toISOString(),
552
+ };
553
+ if (alert.detail !== undefined)
554
+ envelope.detail = alert.detail;
555
+ if (alert.detailFormat !== undefined)
556
+ envelope.detail_format = alert.detailFormat;
557
+ if (alert.category !== undefined)
558
+ envelope.category = alert.category;
559
+ await this.send(JSON.stringify(envelope), {
560
+ messageType: "status_alert",
561
+ priority,
562
+ metadata: { severity: alert.severity },
563
+ });
564
+ }
565
+ async sendArtifact(artifact) {
566
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
567
+ throw new Error("Channel is not ready");
568
+ }
569
+ // Upload encrypted file via existing attachment infrastructure
570
+ const attachMeta = await this._uploadAttachment(artifact.filePath, this._primaryConversationId);
571
+ // Build artifact_share envelope
572
+ const envelope = JSON.stringify({
573
+ type: "artifact",
574
+ blob_id: attachMeta.blobId,
575
+ blob_url: attachMeta.blobUrl,
576
+ filename: artifact.filename,
577
+ mime_type: artifact.mimeType,
578
+ size_bytes: attachMeta.size,
579
+ description: artifact.description,
580
+ attachment: attachMeta,
581
+ });
582
+ const messageGroupId = randomUUID();
583
+ for (const [convId, session] of this._sessions) {
584
+ if (!session.activated)
585
+ continue;
586
+ const encrypted = session.ratchet.encrypt(envelope);
587
+ const transport = encryptedMessageToTransport(encrypted);
588
+ this._ws.send(JSON.stringify({
589
+ event: "message",
590
+ data: {
591
+ conversation_id: convId,
592
+ header_blob: transport.header_blob,
593
+ ciphertext: transport.ciphertext,
594
+ message_group_id: messageGroupId,
595
+ message_type: "artifact_share",
596
+ },
597
+ }));
598
+ }
599
+ await this._persistState();
600
+ }
601
+ async sendActionConfirmation(confirmation) {
602
+ const envelope = {
603
+ type: "action_confirmation",
604
+ action: confirmation.action,
605
+ status: confirmation.status,
606
+ };
607
+ if (confirmation.decisionId !== undefined)
608
+ envelope.decision_id = confirmation.decisionId;
609
+ if (confirmation.detail !== undefined)
610
+ envelope.detail = confirmation.detail;
611
+ await this.send(JSON.stringify(envelope), {
612
+ messageType: "action_confirmation",
613
+ metadata: { status: confirmation.status },
614
+ });
615
+ }
616
+ _sendHeartbeat() {
617
+ if (this._state !== "ready" || !this._heartbeatCallback)
618
+ return;
619
+ const status = this._heartbeatCallback();
620
+ this.send(JSON.stringify({
621
+ agent_status: status.agent_status,
622
+ current_task: status.current_task,
623
+ timestamp: new Date().toISOString(),
624
+ }), {
625
+ messageType: "heartbeat",
626
+ metadata: { next_heartbeat_seconds: this._heartbeatIntervalSeconds },
627
+ }).catch((err) => {
628
+ this.emit("error", new Error(`Heartbeat send failed: ${err}`));
629
+ });
630
+ }
631
+ async stop() {
632
+ this._stopped = true;
633
+ await this.stopHeartbeat();
634
+ this._flushAcks(); // Send any pending ACKs before stopping
635
+ this._stopPing();
636
+ this._stopWakeDetector();
637
+ this._stopPendingPoll();
638
+ this._stopPollFallback();
639
+ this._stopHttpServer();
640
+ if (this._ackTimer) {
641
+ clearTimeout(this._ackTimer);
642
+ this._ackTimer = null;
643
+ }
644
+ if (this._pollTimer) {
645
+ clearTimeout(this._pollTimer);
646
+ this._pollTimer = null;
647
+ }
648
+ if (this._reconnectTimer) {
649
+ clearTimeout(this._reconnectTimer);
650
+ this._reconnectTimer = null;
651
+ }
652
+ if (this._ws) {
653
+ this._ws.removeAllListeners();
654
+ this._ws.close();
655
+ this._ws = null;
656
+ }
657
+ this._setState("disconnected");
658
+ }
659
+ // --- Local HTTP server for proactive sends ---
660
+ startHttpServer(port) {
661
+ if (this._httpServer)
662
+ return;
663
+ this._httpServer = createServer(async (req, res) => {
664
+ // Only accept local connections
665
+ const remote = req.socket.remoteAddress;
666
+ if (remote !== "127.0.0.1" && remote !== "::1" && remote !== "::ffff:127.0.0.1") {
667
+ res.writeHead(403, { "Content-Type": "application/json" });
668
+ res.end(JSON.stringify({ ok: false, error: "Forbidden" }));
669
+ return;
670
+ }
671
+ if (req.method === "POST" && req.url === "/send") {
672
+ let body = "";
673
+ req.on("data", (chunk) => { body += chunk.toString(); });
674
+ req.on("end", async () => {
675
+ try {
676
+ const parsed = JSON.parse(body);
677
+ const text = parsed.text;
678
+ if (!text || typeof text !== "string") {
679
+ res.writeHead(400, { "Content-Type": "application/json" });
680
+ res.end(JSON.stringify({ ok: false, error: "Missing 'text' field" }));
681
+ return;
682
+ }
683
+ // Optional file attachment
684
+ if (parsed.file_path && typeof parsed.file_path === "string") {
685
+ await this.sendWithAttachment(text, parsed.file_path, { topicId: parsed.topicId });
686
+ }
687
+ else {
688
+ await this.send(text, { topicId: parsed.topicId });
689
+ }
690
+ res.writeHead(200, { "Content-Type": "application/json" });
691
+ res.end(JSON.stringify({ ok: true }));
692
+ }
693
+ catch (err) {
694
+ res.writeHead(500, { "Content-Type": "application/json" });
695
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
696
+ }
697
+ });
698
+ }
699
+ else if (req.method === "GET" && req.url === "/status") {
700
+ res.writeHead(200, { "Content-Type": "application/json" });
701
+ res.end(JSON.stringify({
702
+ ok: true,
703
+ state: this._state,
704
+ deviceId: this._deviceId,
705
+ sessions: this._sessions.size,
706
+ }));
707
+ }
708
+ else {
709
+ res.writeHead(404, { "Content-Type": "application/json" });
710
+ res.end(JSON.stringify({ ok: false, error: "Not found. Use POST /send or GET /status" }));
711
+ }
712
+ });
713
+ this._httpServer.listen(port, "127.0.0.1", () => {
714
+ this.emit("http-ready", port);
715
+ });
716
+ }
717
+ _stopHttpServer() {
718
+ if (this._httpServer) {
719
+ this._httpServer.close();
720
+ this._httpServer = null;
721
+ }
722
+ }
723
+ // --- Topic management ---
724
+ /**
725
+ * Create a new topic within the conversation group.
726
+ * Requires the channel to be initialized with a groupId (from activation).
727
+ */
728
+ async createTopic(name) {
729
+ if (!this._persisted?.groupId) {
730
+ throw new Error("Channel not initialized or groupId unknown");
731
+ }
732
+ if (!this._deviceJwt) {
733
+ throw new Error("Channel not authenticated");
734
+ }
735
+ const res = await fetch(`${this.config.apiUrl}/api/v1/topics`, {
736
+ method: "POST",
737
+ headers: {
738
+ "Content-Type": "application/json",
739
+ Authorization: `Bearer ${this._deviceJwt}`,
740
+ },
741
+ body: JSON.stringify({
742
+ group_id: this._persisted.groupId,
743
+ name,
744
+ creator_device_id: this._persisted.deviceId,
745
+ }),
746
+ });
747
+ if (!res.ok) {
748
+ const detail = await res.text();
749
+ throw new Error(`Create topic failed (${res.status}): ${detail}`);
750
+ }
751
+ const resp = await res.json();
752
+ const topic = { id: resp.id, name: resp.name, isDefault: resp.is_default };
753
+ if (!this._persisted.topics) {
754
+ this._persisted.topics = [];
755
+ }
756
+ this._persisted.topics.push(topic);
757
+ await this._persistState();
758
+ return topic;
759
+ }
760
+ /**
761
+ * List all topics in the conversation group.
762
+ * Requires the channel to be initialized with a groupId (from activation).
763
+ */
764
+ async listTopics() {
765
+ if (!this._persisted?.groupId) {
766
+ throw new Error("Channel not initialized or groupId unknown");
767
+ }
768
+ if (!this._deviceJwt) {
769
+ throw new Error("Channel not authenticated");
770
+ }
771
+ const res = await fetch(`${this.config.apiUrl}/api/v1/topics?group_id=${encodeURIComponent(this._persisted.groupId)}`, {
772
+ headers: {
773
+ Authorization: `Bearer ${this._deviceJwt}`,
774
+ },
775
+ });
776
+ if (!res.ok) {
777
+ const detail = await res.text();
778
+ throw new Error(`List topics failed (${res.status}): ${detail}`);
779
+ }
780
+ const resp = await res.json();
781
+ const topics = resp.map((t) => ({ id: t.id, name: t.name, isDefault: t.is_default }));
782
+ this._persisted.topics = topics;
783
+ await this._persistState();
784
+ return topics;
785
+ }
786
+ // --- A2A Channel methods (agent-to-agent communication) ---
787
+ /**
788
+ * Request a new A2A channel with another agent by their hub address.
789
+ * Returns the channel_id from the server response.
790
+ */
791
+ async requestA2AChannel(responderHubAddress) {
792
+ if (!this._persisted?.hubAddress) {
793
+ throw new Error("This agent does not have a hub address assigned");
794
+ }
795
+ if (!this._deviceJwt) {
796
+ throw new Error("Channel not authenticated");
797
+ }
798
+ const res = await fetch(`${this.config.apiUrl}/api/v1/a2a/channels/request`, {
799
+ method: "POST",
800
+ headers: {
801
+ "Content-Type": "application/json",
802
+ Authorization: `Bearer ${this._deviceJwt}`,
803
+ },
804
+ body: JSON.stringify({
805
+ initiator_hub_address: this._persisted.hubAddress,
806
+ responder_hub_address: responderHubAddress,
807
+ }),
808
+ });
809
+ if (!res.ok) {
810
+ const detail = await res.text();
811
+ throw new Error(`A2A channel request failed (${res.status}): ${detail}`);
812
+ }
813
+ const resp = await res.json();
814
+ const channelId = resp.channel_id || resp.id;
815
+ // Store channel info in persisted state
816
+ if (!this._persisted.a2aChannels) {
817
+ this._persisted.a2aChannels = {};
818
+ }
819
+ this._persisted.a2aChannels[channelId] = {
820
+ channelId,
821
+ hubAddress: responderHubAddress,
822
+ conversationId: resp.conversation_id || "",
823
+ };
824
+ await this._persistState();
825
+ return channelId;
826
+ }
827
+ /**
828
+ * Send a message to another agent via an active A2A channel.
829
+ * Looks up the A2A conversation by hub address and sends via WS.
830
+ *
831
+ * If the channel has an established E2E session, the message is encrypted
832
+ * with the Double Ratchet. If the responder hasn't received the initiator's
833
+ * first message yet (ratchet not activated), the message is queued locally
834
+ * and flushed when the first inbound message arrives.
835
+ *
836
+ * Falls back to plaintext for channels without a session (legacy/pre-encryption).
837
+ */
838
+ async sendToAgent(hubAddress, text, opts) {
839
+ if (!this._persisted?.a2aChannels) {
840
+ throw new Error("No A2A channels established");
841
+ }
842
+ // Find the active channel for this hub address
843
+ const channelEntry = Object.values(this._persisted.a2aChannels).find((ch) => ch.hubAddress === hubAddress);
844
+ if (!channelEntry) {
845
+ throw new Error(`No A2A channel found for hub address: ${hubAddress}`);
846
+ }
847
+ if (!channelEntry.conversationId) {
848
+ throw new Error(`A2A channel ${channelEntry.channelId} does not have a conversation yet (not activated)`);
849
+ }
850
+ if (this._state !== "ready" || !this._ws) {
851
+ throw new Error("Channel is not connected");
852
+ }
853
+ // --- E2E encrypted path ---
854
+ if (channelEntry.session?.ratchetState) {
855
+ // Responder must wait for first initiator message before sending
856
+ if (channelEntry.role === "responder" && !channelEntry.session.activated) {
857
+ if (!this._a2aPendingQueue[channelEntry.channelId]) {
858
+ this._a2aPendingQueue[channelEntry.channelId] = [];
859
+ }
860
+ this._a2aPendingQueue[channelEntry.channelId].push({ text, opts });
861
+ console.log(`[SecureChannel] A2A message queued (responder not yet activated, ` +
862
+ `queue=${this._a2aPendingQueue[channelEntry.channelId].length})`);
863
+ return;
864
+ }
865
+ // Deserialize ratchet, encrypt, update state
866
+ const ratchet = DoubleRatchet.deserialize(channelEntry.session.ratchetState);
867
+ const encrypted = ratchet.encrypt(text);
868
+ // Serialize header for transport (hex-encoded JSON)
869
+ const headerObj = {
870
+ dhPublicKey: bytesToHex(encrypted.header.dhPublicKey),
871
+ previousChainLength: encrypted.header.previousChainLength,
872
+ messageNumber: encrypted.header.messageNumber,
873
+ };
874
+ const headerBlobHex = Buffer.from(JSON.stringify(headerObj)).toString("hex");
875
+ const payload = {
876
+ channel_id: channelEntry.channelId,
877
+ conversation_id: channelEntry.conversationId,
878
+ header_blob: headerBlobHex,
879
+ header_signature: bytesToHex(encrypted.headerSignature),
880
+ ciphertext: bytesToHex(encrypted.ciphertext),
881
+ nonce: bytesToHex(encrypted.nonce),
882
+ parent_span_id: opts?.parentSpanId,
883
+ };
884
+ if (this._persisted.hubAddress) {
885
+ payload.hub_address = this._persisted.hubAddress;
886
+ }
887
+ // Update persisted ratchet state
888
+ channelEntry.session.ratchetState = ratchet.serialize();
889
+ await this._persistState();
890
+ this._ws.send(JSON.stringify({
891
+ event: "a2a_message",
892
+ data: payload,
893
+ }));
894
+ return;
895
+ }
896
+ // --- Legacy plaintext fallback (no session) ---
897
+ const payload = {
898
+ conversation_id: channelEntry.conversationId,
899
+ plaintext: text,
900
+ message_type: "a2a",
901
+ parent_span_id: opts?.parentSpanId,
902
+ };
903
+ if (this._persisted.hubAddress) {
904
+ payload.hub_address = this._persisted.hubAddress;
905
+ }
906
+ this._ws.send(JSON.stringify({
907
+ event: "a2a_message",
908
+ data: payload,
909
+ }));
910
+ }
911
+ /**
912
+ * List all A2A channels for this agent.
913
+ * Fetches from the server and updates local persisted state.
914
+ */
915
+ async listA2AChannels() {
916
+ if (!this._deviceJwt) {
917
+ throw new Error("Channel not authenticated");
918
+ }
919
+ const res = await fetch(`${this.config.apiUrl}/api/v1/a2a/channels`, {
920
+ headers: {
921
+ Authorization: `Bearer ${this._deviceJwt}`,
922
+ },
923
+ });
924
+ if (!res.ok) {
925
+ const detail = await res.text();
926
+ throw new Error(`List A2A channels failed (${res.status}): ${detail}`);
927
+ }
928
+ const resp = await res.json();
929
+ const channels = resp.map((ch) => ({
930
+ channelId: ch.channel_id || ch.id,
931
+ initiatorHubAddress: ch.initiator_hub_address,
932
+ responderHubAddress: ch.responder_hub_address,
933
+ conversationId: ch.conversation_id,
934
+ status: ch.status,
935
+ createdAt: ch.created_at,
936
+ }));
937
+ // Update persisted a2aChannels map with latest server data
938
+ if (this._persisted) {
939
+ if (!this._persisted.a2aChannels) {
940
+ this._persisted.a2aChannels = {};
941
+ }
942
+ for (const ch of channels) {
943
+ if (ch.status === "active" || ch.status === "approved") {
944
+ const otherAddress = ch.initiatorHubAddress === this._persisted.hubAddress
945
+ ? ch.responderHubAddress
946
+ : ch.initiatorHubAddress;
947
+ this._persisted.a2aChannels[ch.channelId] = {
948
+ channelId: ch.channelId,
949
+ hubAddress: otherAddress,
950
+ conversationId: ch.conversationId || "",
951
+ };
952
+ }
953
+ }
954
+ await this._persistState();
955
+ }
956
+ return channels;
957
+ }
958
+ // --- Internal lifecycle ---
959
+ async _enroll() {
960
+ this._setState("enrolling");
961
+ try {
962
+ const identity = await generateIdentityKeypair();
963
+ const ephemeral = await generateEphemeralKeypair();
964
+ const fingerprint = computeFingerprint(identity.publicKey);
965
+ const proof = createProofOfPossession(identity.privateKey, identity.publicKey);
966
+ const result = await enrollDevice(this.config.apiUrl, this.config.inviteToken, bytesToHex(identity.publicKey), bytesToHex(ephemeral.publicKey), bytesToHex(proof), this.config.platform);
967
+ this._deviceId = result.device_id;
968
+ this._fingerprint = result.fingerprint;
969
+ // Store keypairs temporarily for activation (not persisted yet -- no JWT)
970
+ this._persisted = {
971
+ deviceId: result.device_id,
972
+ deviceJwt: "", // set after activation
973
+ primaryConversationId: "", // set after activation
974
+ sessions: {}, // populated after activation
975
+ identityKeypair: {
976
+ publicKey: bytesToHex(identity.publicKey),
977
+ privateKey: bytesToHex(identity.privateKey),
978
+ },
979
+ ephemeralKeypair: {
980
+ publicKey: bytesToHex(ephemeral.publicKey),
981
+ privateKey: bytesToHex(ephemeral.privateKey),
982
+ },
983
+ fingerprint: result.fingerprint,
984
+ messageHistory: [],
985
+ };
986
+ this._poll();
987
+ }
988
+ catch (err) {
989
+ this._handleError(err);
990
+ }
991
+ }
992
+ _poll() {
993
+ if (this._stopped)
994
+ return;
995
+ this._setState("polling");
996
+ const doPoll = async () => {
997
+ if (this._stopped)
998
+ return;
999
+ try {
1000
+ const status = await pollDeviceStatus(this.config.apiUrl, this._deviceId);
1001
+ if (status.rateLimited) {
1002
+ // Server rate-limited this poll — back off 2x before retrying
1003
+ this._pollTimer = setTimeout(doPoll, POLL_INTERVAL_MS * 2);
1004
+ return;
1005
+ }
1006
+ if (status.status === "APPROVED") {
1007
+ await this._activate();
1008
+ return;
1009
+ }
1010
+ if (status.status === "REVOKED") {
1011
+ this._handleError(new Error("Device was revoked"));
1012
+ return;
1013
+ }
1014
+ // Still PENDING -- poll again
1015
+ this._pollTimer = setTimeout(doPoll, POLL_INTERVAL_MS);
1016
+ }
1017
+ catch (err) {
1018
+ this._handleError(err);
1019
+ }
1020
+ };
1021
+ this._pollTimer = setTimeout(doPoll, POLL_INTERVAL_MS);
1022
+ }
1023
+ async _activate() {
1024
+ this._setState("activating");
1025
+ try {
1026
+ const result = await activateDevice(this.config.apiUrl, this._deviceId);
1027
+ // Support both old (single conversation_id) and new (conversations array) response
1028
+ const conversations = result.conversations || [
1029
+ {
1030
+ conversation_id: result.conversation_id,
1031
+ owner_device_id: "",
1032
+ is_primary: true,
1033
+ },
1034
+ ];
1035
+ const primary = conversations.find((c) => c.is_primary) || conversations[0];
1036
+ this._primaryConversationId = primary.conversation_id;
1037
+ this._deviceJwt = result.device_jwt;
1038
+ const identity = this._persisted.identityKeypair;
1039
+ const ephemeral = this._persisted.ephemeralKeypair;
1040
+ const sessions = {};
1041
+ // Initialize X3DH + ratchet for EVERY conversation (not just primary)
1042
+ for (const conv of conversations) {
1043
+ // Use per-conversation owner keys if available, fallback to primary keys
1044
+ const ownerIdentityKey = conv.owner_identity_public_key || result.owner_identity_public_key;
1045
+ const ownerEphemeralKey = conv.owner_ephemeral_public_key ||
1046
+ result.owner_ephemeral_public_key ||
1047
+ ownerIdentityKey;
1048
+ const sharedSecret = performX3DH({
1049
+ myIdentityPrivate: hexToBytes(identity.privateKey),
1050
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
1051
+ theirIdentityPublic: hexToBytes(ownerIdentityKey),
1052
+ theirEphemeralPublic: hexToBytes(ownerEphemeralKey),
1053
+ isInitiator: false,
1054
+ });
1055
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
1056
+ publicKey: hexToBytes(identity.publicKey),
1057
+ privateKey: hexToBytes(identity.privateKey),
1058
+ keyType: "ed25519",
1059
+ });
1060
+ this._sessions.set(conv.conversation_id, {
1061
+ ownerDeviceId: conv.owner_device_id,
1062
+ ratchet,
1063
+ activated: false, // Wait for owner's first message before sending to this session
1064
+ });
1065
+ sessions[conv.conversation_id] = {
1066
+ ownerDeviceId: conv.owner_device_id,
1067
+ ratchetState: ratchet.serialize(),
1068
+ };
1069
+ 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})`);
1070
+ }
1071
+ // Persist full state (preserve existing messageHistory)
1072
+ this._persisted = {
1073
+ ...this._persisted,
1074
+ deviceJwt: result.device_jwt,
1075
+ primaryConversationId: primary.conversation_id,
1076
+ sessions,
1077
+ messageHistory: this._persisted.messageHistory ?? [],
1078
+ };
1079
+ // Store group_id and default_topic_id for topic API calls
1080
+ if (conversations.length > 0) {
1081
+ const firstConv = conversations[0];
1082
+ if (firstConv.group_id) {
1083
+ this._persisted.groupId = firstConv.group_id;
1084
+ }
1085
+ if (firstConv.default_topic_id) {
1086
+ this._persisted.defaultTopicId = firstConv.default_topic_id;
1087
+ }
1088
+ }
1089
+ await saveState(this.config.dataDir, this._persisted);
1090
+ // Register webhook if configured
1091
+ if (this.config.webhookUrl) {
1092
+ try {
1093
+ const webhookResp = await fetch(`${this.config.apiUrl}/api/v1/devices/self/webhook`, {
1094
+ method: "PATCH",
1095
+ headers: {
1096
+ "Content-Type": "application/json",
1097
+ Authorization: `Bearer ${this._deviceJwt}`,
1098
+ },
1099
+ body: JSON.stringify({ webhook_url: this.config.webhookUrl }),
1100
+ });
1101
+ if (webhookResp.ok) {
1102
+ const webhookData = await webhookResp.json();
1103
+ console.log(`[SecureChannel] Webhook registered: ${this.config.webhookUrl} (secret: ${webhookData.webhook_secret?.slice(0, 8)}...)`);
1104
+ this.emit("webhook_registered", {
1105
+ url: this.config.webhookUrl,
1106
+ secret: webhookData.webhook_secret,
1107
+ });
1108
+ }
1109
+ else {
1110
+ console.warn(`[SecureChannel] Webhook registration failed: ${webhookResp.status}`);
1111
+ }
1112
+ }
1113
+ catch (err) {
1114
+ console.warn(`[SecureChannel] Webhook registration error: ${err}`);
1115
+ }
1116
+ }
1117
+ this._connect();
1118
+ }
1119
+ catch (err) {
1120
+ this._handleError(err);
1121
+ }
1122
+ }
1123
+ _connect() {
1124
+ if (this._stopped)
1125
+ return;
1126
+ // Clear reconnect timer — we're connecting now, so any pending reconnect is superseded.
1127
+ if (this._reconnectTimer) {
1128
+ clearTimeout(this._reconnectTimer);
1129
+ this._reconnectTimer = null;
1130
+ }
1131
+ // Clean up existing WebSocket to prevent reconnect cascade:
1132
+ // The server disconnects old WS when same device reconnects, which fires
1133
+ // the old close handler → _scheduleReconnect() → new _connect() → cascade.
1134
+ if (this._ws) {
1135
+ this._ws.removeAllListeners();
1136
+ try {
1137
+ this._ws.close();
1138
+ }
1139
+ catch { /* already closed */ }
1140
+ this._ws = null;
1141
+ }
1142
+ this._setState("connecting");
1143
+ const wsUrl = this.config.apiUrl.replace(/^http/, "ws");
1144
+ const url = `${wsUrl}/api/v1/ws?token=${encodeURIComponent(this._deviceJwt)}&device_id=${this._deviceId}`;
1145
+ const ws = new WebSocket(url);
1146
+ this._ws = ws;
1147
+ ws.on("open", async () => {
1148
+ this._reconnectAttempt = 0;
1149
+ this._startPing(ws);
1150
+ this._startWakeDetector();
1151
+ this._startPendingPoll();
1152
+ // Sync missed messages before declaring ready
1153
+ await this._syncMissedMessages();
1154
+ await this._flushOutboundQueue();
1155
+ this._setState("ready");
1156
+ // Initialize client-side policy scanning if enabled
1157
+ if (this.config.enableScanning) {
1158
+ this._scanEngine = new ScanEngine();
1159
+ await this._fetchScanRules();
1160
+ }
1161
+ this.emit("ready");
1162
+ });
1163
+ ws.on("message", async (raw) => {
1164
+ this._lastServerMessage = Date.now();
1165
+ this._lastWakeTick = Date.now();
1166
+ try {
1167
+ const data = JSON.parse(raw.toString());
1168
+ if (data.event === "ping") {
1169
+ ws.send(JSON.stringify({ event: "pong" }));
1170
+ return;
1171
+ }
1172
+ if (data.event === "device_revoked") {
1173
+ await clearState(this.config.dataDir);
1174
+ this._handleError(new Error("Device was revoked"));
1175
+ return;
1176
+ }
1177
+ if (data.event === "device_linked") {
1178
+ await this._handleDeviceLinked(data.data);
1179
+ return;
1180
+ }
1181
+ if (data.event === "message") {
1182
+ await this._handleIncomingMessage(data.data);
1183
+ }
1184
+ if (data.event === "room_joined") {
1185
+ const d = data.data;
1186
+ this.joinRoom({
1187
+ roomId: d.room_id,
1188
+ name: d.name,
1189
+ members: (d.members || []).map((m) => ({
1190
+ deviceId: m.device_id,
1191
+ entityType: m.entity_type,
1192
+ displayName: m.display_name,
1193
+ identityPublicKey: m.identity_public_key,
1194
+ ephemeralPublicKey: m.ephemeral_public_key,
1195
+ })),
1196
+ conversations: (d.conversations || []).map((c) => ({
1197
+ id: c.id,
1198
+ participantA: c.participant_a,
1199
+ participantB: c.participant_b,
1200
+ })),
1201
+ }).catch((err) => this.emit("error", err));
1202
+ }
1203
+ if (data.event === "room_message") {
1204
+ await this._handleRoomMessage(data.data);
1205
+ }
1206
+ if (data.event === "room_participant_added") {
1207
+ const p = data.data;
1208
+ this.emit("room_participant_added", {
1209
+ roomId: p.room_id,
1210
+ participant: {
1211
+ deviceId: p.participant?.device_id ?? p.device_id,
1212
+ participantType: p.participant?.participant_type ?? p.participant_type ?? "human",
1213
+ displayName: p.participant?.display_name ?? p.display_name,
1214
+ },
1215
+ });
1216
+ }
1217
+ if (data.event === "room_participant_removed") {
1218
+ const p = data.data;
1219
+ this.emit("room_participant_removed", {
1220
+ roomId: p.room_id,
1221
+ participant: {
1222
+ deviceId: p.participant?.device_id ?? p.device_id,
1223
+ participantType: p.participant?.participant_type ?? p.participant_type ?? "human",
1224
+ displayName: p.participant?.display_name ?? p.display_name,
1225
+ },
1226
+ });
1227
+ }
1228
+ if (data.event === "policy_blocked") {
1229
+ this.emit("policy_blocked", data.data);
1230
+ }
1231
+ if (data.event === "message_held") {
1232
+ this.emit("message_held", data.data);
1233
+ }
1234
+ if (data.event === "policy_rejected") {
1235
+ this.emit("policy_rejected", data.data);
1236
+ }
1237
+ if (data.event === "scan_rules_updated") {
1238
+ await this._fetchScanRules();
1239
+ return;
1240
+ }
1241
+ if (data.event === "hub_identity_assigned") {
1242
+ if (this._persisted) {
1243
+ this._persisted.hubAddress = data.data.hub_address;
1244
+ this._persistState();
1245
+ }
1246
+ this.emit("hub_identity_assigned", data.data);
1247
+ }
1248
+ if (data.event === "hub_identity_removed") {
1249
+ if (this._persisted) {
1250
+ delete this._persisted.hubAddress;
1251
+ this._persistState();
1252
+ }
1253
+ this.emit("hub_identity_removed", data.data);
1254
+ }
1255
+ // --- A2A Channel WS events ---
1256
+ if (data.event === "a2a_channel_approved") {
1257
+ const channelData = data.data || data;
1258
+ const channelId = channelData.channel_id;
1259
+ const convId = channelData.conversation_id;
1260
+ // Determine role: if we are the initiator hub address, we initiated
1261
+ const myHub = this._persisted?.hubAddress || "";
1262
+ const role = channelData.role ||
1263
+ (channelData.initiator_hub_address === myHub ? "initiator" : "responder");
1264
+ const peerHub = role === "initiator"
1265
+ ? channelData.responder_hub_address || ""
1266
+ : channelData.initiator_hub_address || "";
1267
+ // Store in persisted state
1268
+ if (this._persisted && convId) {
1269
+ if (!this._persisted.a2aChannels)
1270
+ this._persisted.a2aChannels = {};
1271
+ // Generate ephemeral X25519 keypair for key exchange
1272
+ const a2aEphemeral = await generateEphemeralKeypair();
1273
+ const ephPubHex = bytesToHex(a2aEphemeral.publicKey);
1274
+ const ephPrivHex = bytesToHex(a2aEphemeral.privateKey);
1275
+ this._persisted.a2aChannels[channelId] = {
1276
+ channelId,
1277
+ hubAddress: peerHub,
1278
+ conversationId: convId,
1279
+ role,
1280
+ pendingEphemeralPrivateKey: ephPrivHex,
1281
+ };
1282
+ // Send ephemeral public key to server for key exchange
1283
+ if (this._ws) {
1284
+ this._ws.send(JSON.stringify({
1285
+ event: "a2a_key_exchange",
1286
+ data: {
1287
+ channel_id: channelId,
1288
+ ephemeral_key: ephPubHex,
1289
+ },
1290
+ }));
1291
+ console.log(`[SecureChannel] A2A key exchange sent for channel ${channelId.slice(0, 8)}... (role=${role})`);
1292
+ }
1293
+ await this._persistState();
1294
+ }
1295
+ this.emit("a2a_channel_approved", channelData);
1296
+ }
1297
+ if (data.event === "a2a_channel_activated") {
1298
+ const actData = data.data || data;
1299
+ const channelId = actData.channel_id;
1300
+ const convId = actData.conversation_id;
1301
+ const activatedRole = actData.role;
1302
+ const peerIdentityHex = actData.peer_identity_key;
1303
+ const peerEphemeralHex = actData.peer_ephemeral_key;
1304
+ const channelEntry = this._persisted?.a2aChannels?.[channelId];
1305
+ if (channelEntry && channelEntry.pendingEphemeralPrivateKey && this._persisted) {
1306
+ try {
1307
+ // Reconstruct keys from hex
1308
+ const myIdentityPrivate = hexToBytes(this._persisted.identityKeypair.privateKey);
1309
+ const myIdentityPublic = hexToBytes(this._persisted.identityKeypair.publicKey);
1310
+ const myEphemeralPrivate = hexToBytes(channelEntry.pendingEphemeralPrivateKey);
1311
+ const theirIdentityPublic = hexToBytes(peerIdentityHex);
1312
+ const theirEphemeralPublic = hexToBytes(peerEphemeralHex);
1313
+ // Perform X3DH key agreement
1314
+ const sharedSecret = performX3DH({
1315
+ myIdentityPrivate,
1316
+ myEphemeralPrivate,
1317
+ theirIdentityPublic,
1318
+ theirEphemeralPublic,
1319
+ isInitiator: activatedRole === "initiator",
1320
+ });
1321
+ // Initialize Double Ratchet
1322
+ const identityKp = {
1323
+ publicKey: myIdentityPublic,
1324
+ privateKey: myIdentityPrivate,
1325
+ keyType: "ed25519",
1326
+ };
1327
+ const ratchet = activatedRole === "initiator"
1328
+ ? DoubleRatchet.initSender(sharedSecret, identityKp)
1329
+ : DoubleRatchet.initReceiver(sharedSecret, identityKp);
1330
+ // Store session state
1331
+ channelEntry.session = {
1332
+ ownerDeviceId: "", // A2A sessions don't have an owner device
1333
+ ratchetState: ratchet.serialize(),
1334
+ activated: activatedRole === "initiator", // initiator can send immediately
1335
+ };
1336
+ channelEntry.role = activatedRole;
1337
+ if (convId)
1338
+ channelEntry.conversationId = convId;
1339
+ // Clean up ephemeral private key (no longer needed)
1340
+ delete channelEntry.pendingEphemeralPrivateKey;
1341
+ await this._persistState();
1342
+ console.log(`[SecureChannel] A2A channel activated: ${channelId.slice(0, 8)}... (role=${activatedRole}, ` +
1343
+ `canSend=${activatedRole === "initiator"})`);
1344
+ }
1345
+ catch (err) {
1346
+ console.error(`[SecureChannel] A2A activation failed for ${channelId.slice(0, 8)}...:`, err);
1347
+ this.emit("error", err);
1348
+ }
1349
+ }
1350
+ this.emit("a2a_channel_activated", actData);
1351
+ }
1352
+ if (data.event === "a2a_channel_rejected") {
1353
+ this.emit("a2a_channel_rejected", data.data || data);
1354
+ }
1355
+ if (data.event === "a2a_channel_revoked") {
1356
+ const channelData = data.data || data;
1357
+ const channelId = channelData.channel_id;
1358
+ // Remove from persisted state
1359
+ if (this._persisted?.a2aChannels?.[channelId]) {
1360
+ delete this._persisted.a2aChannels[channelId];
1361
+ await this._persistState();
1362
+ }
1363
+ this.emit("a2a_channel_revoked", channelData);
1364
+ }
1365
+ if (data.event === "a2a_message") {
1366
+ const msgData = data.data || data;
1367
+ // --- Encrypted A2A message (has ciphertext field) ---
1368
+ if (msgData.ciphertext && msgData.header_blob) {
1369
+ const channelId = msgData.channel_id || "";
1370
+ const channelEntry = this._persisted?.a2aChannels?.[channelId];
1371
+ if (channelEntry?.session?.ratchetState) {
1372
+ try {
1373
+ // Reconstruct EncryptedMessage from hex transport
1374
+ const headerJson = Buffer.from(msgData.header_blob, "hex").toString("utf-8");
1375
+ const headerObj = JSON.parse(headerJson);
1376
+ const encryptedMessage = {
1377
+ header: {
1378
+ dhPublicKey: hexToBytes(headerObj.dhPublicKey),
1379
+ previousChainLength: headerObj.previousChainLength,
1380
+ messageNumber: headerObj.messageNumber,
1381
+ },
1382
+ headerSignature: hexToBytes(msgData.header_signature),
1383
+ ciphertext: hexToBytes(msgData.ciphertext),
1384
+ nonce: hexToBytes(msgData.nonce),
1385
+ };
1386
+ // Decrypt
1387
+ const ratchet = DoubleRatchet.deserialize(channelEntry.session.ratchetState);
1388
+ const plaintext = ratchet.decrypt(encryptedMessage);
1389
+ // Update ratchet state
1390
+ channelEntry.session.ratchetState = ratchet.serialize();
1391
+ // If responder and not yet activated, this is the first initiator message
1392
+ if (channelEntry.role === "responder" && !channelEntry.session.activated) {
1393
+ channelEntry.session.activated = true;
1394
+ console.log(`[SecureChannel] A2A responder ratchet activated for ${channelId.slice(0, 8)}...`);
1395
+ // Flush any queued messages
1396
+ const queued = this._a2aPendingQueue[channelId];
1397
+ if (queued && queued.length > 0) {
1398
+ delete this._a2aPendingQueue[channelId];
1399
+ console.log(`[SecureChannel] Flushing ${queued.length} queued A2A message(s)`);
1400
+ // Flush asynchronously after persisting current state
1401
+ await this._persistState();
1402
+ for (const pending of queued) {
1403
+ try {
1404
+ await this.sendToAgent(channelEntry.hubAddress, pending.text, pending.opts);
1405
+ }
1406
+ catch (flushErr) {
1407
+ console.error("[SecureChannel] Failed to flush queued A2A message:", flushErr);
1408
+ }
1409
+ }
1410
+ }
1411
+ }
1412
+ await this._persistState();
1413
+ const a2aMsg = {
1414
+ text: plaintext,
1415
+ fromHubAddress: msgData.from_hub_address || msgData.hub_address || "",
1416
+ channelId,
1417
+ conversationId: msgData.conversation_id || "",
1418
+ parentSpanId: msgData.parent_span_id,
1419
+ timestamp: msgData.timestamp || new Date().toISOString(),
1420
+ };
1421
+ this.emit("a2a_message", a2aMsg);
1422
+ if (this.config.onA2AMessage) {
1423
+ this.config.onA2AMessage(a2aMsg);
1424
+ }
1425
+ }
1426
+ catch (decryptErr) {
1427
+ console.error(`[SecureChannel] A2A decrypt failed for channel ${channelId.slice(0, 8)}...:`, decryptErr);
1428
+ this.emit("error", decryptErr);
1429
+ }
1430
+ }
1431
+ else {
1432
+ console.warn(`[SecureChannel] Received encrypted A2A message but no session for channel ${channelId}`);
1433
+ }
1434
+ }
1435
+ else {
1436
+ // --- Legacy plaintext A2A message ---
1437
+ const a2aMsg = {
1438
+ text: msgData.plaintext || msgData.text,
1439
+ fromHubAddress: msgData.from_hub_address || msgData.hub_address || "",
1440
+ channelId: msgData.channel_id || "",
1441
+ conversationId: msgData.conversation_id || "",
1442
+ parentSpanId: msgData.parent_span_id,
1443
+ timestamp: msgData.timestamp || new Date().toISOString(),
1444
+ };
1445
+ this.emit("a2a_message", a2aMsg);
1446
+ if (this.config.onA2AMessage) {
1447
+ this.config.onA2AMessage(a2aMsg);
1448
+ }
1449
+ }
1450
+ }
1451
+ }
1452
+ catch (err) {
1453
+ this.emit("error", err);
1454
+ }
1455
+ });
1456
+ ws.on("close", () => {
1457
+ this._stopPing();
1458
+ this._stopWakeDetector();
1459
+ this._stopPendingPoll();
1460
+ if (this._stopped)
1461
+ return;
1462
+ this._setState("disconnected");
1463
+ this._scheduleReconnect();
1464
+ });
1465
+ ws.on("error", (err) => {
1466
+ this.emit("error", err);
1467
+ // The close event will fire after this and handle reconnection
1468
+ });
1469
+ }
1470
+ /**
1471
+ * Handle an incoming encrypted message from a specific conversation.
1472
+ * Decrypts using the appropriate session ratchet, emits to the agent,
1473
+ * and relays as sync messages to sibling sessions.
1474
+ */
1475
+ async _handleIncomingMessage(msgData) {
1476
+ // Don't decrypt our own messages
1477
+ if (msgData.sender_device_id === this._deviceId)
1478
+ return;
1479
+ // Dedup: skip if this message was already processed during sync
1480
+ if (this._syncMessageIds?.has(msgData.message_id)) {
1481
+ return;
1482
+ }
1483
+ const convId = msgData.conversation_id;
1484
+ const session = this._sessions.get(convId);
1485
+ if (!session) {
1486
+ // Could be a message for a conversation we haven't initialized yet
1487
+ console.warn(`[SecureChannel] No session for conversation ${convId}, skipping`);
1488
+ return;
1489
+ }
1490
+ const encrypted = transportToEncryptedMessage({
1491
+ header_blob: msgData.header_blob,
1492
+ ciphertext: msgData.ciphertext,
1493
+ });
1494
+ const plaintext = session.ratchet.decrypt(encrypted);
1495
+ // ACK delivery to server
1496
+ this._sendAck(msgData.message_id);
1497
+ // Mark session as activated on first owner message
1498
+ if (!session.activated) {
1499
+ session.activated = true;
1500
+ console.log(`[SecureChannel] Session ${convId.slice(0, 8)}... activated by first owner message`);
1501
+ }
1502
+ // Parse structured payload (new format) or treat as raw text (legacy)
1503
+ let messageText;
1504
+ let messageType;
1505
+ let attachmentInfo = null;
1506
+ try {
1507
+ const parsed = JSON.parse(plaintext);
1508
+ messageType = parsed.type || "message";
1509
+ messageText = parsed.text || plaintext;
1510
+ if (parsed.attachment) {
1511
+ attachmentInfo = parsed.attachment;
1512
+ }
1513
+ }
1514
+ catch {
1515
+ // Legacy plain string format
1516
+ messageType = "message";
1517
+ messageText = plaintext;
1518
+ }
1519
+ if (messageType === "session_init") {
1520
+ // New device session activated — replay message history
1521
+ console.log(`[SecureChannel] session_init received for ${convId.slice(0, 8)}..., replaying history`);
1522
+ await this._replayHistoryToSession(convId);
1523
+ await this._persistState();
1524
+ return;
1525
+ }
1526
+ // Inbound scan — after decryption
1527
+ if (this._scanEngine) {
1528
+ const scanResult = this._scanEngine.scanInbound(messageText);
1529
+ if (scanResult.status === "blocked") {
1530
+ this.emit("scan_blocked", { direction: "inbound", violations: scanResult.violations });
1531
+ return; // drop the message
1532
+ }
1533
+ }
1534
+ if (messageType === "message") {
1535
+ const topicId = msgData.topic_id;
1536
+ // Handle attachment: download, decrypt, save to disk
1537
+ let attachData;
1538
+ if (attachmentInfo) {
1539
+ try {
1540
+ const { filePath, decrypted } = await this._downloadAndDecryptAttachment(attachmentInfo);
1541
+ attachData = {
1542
+ filename: attachmentInfo.filename,
1543
+ mime: attachmentInfo.mime,
1544
+ size: decrypted.length,
1545
+ filePath,
1546
+ };
1547
+ // For images: include base64 so LLM vision can see the content
1548
+ if (attachmentInfo.mime.startsWith("image/")) {
1549
+ attachData.base64 = `data:${attachmentInfo.mime};base64,${bytesToBase64(decrypted)}`;
1550
+ }
1551
+ // For text-based files: extract content inline
1552
+ const textMimes = ["text/", "application/json", "application/xml", "application/csv"];
1553
+ if (textMimes.some((m) => attachmentInfo.mime.startsWith(m))) {
1554
+ attachData.textContent = new TextDecoder().decode(decrypted);
1555
+ }
1556
+ }
1557
+ catch (err) {
1558
+ console.error(`[SecureChannel] Failed to download attachment:`, err);
1559
+ }
1560
+ }
1561
+ // Store owner message in history for cross-device replay
1562
+ this._appendHistory("owner", messageText, topicId);
1563
+ // Build display text with attachment content
1564
+ let emitText = messageText;
1565
+ if (attachData) {
1566
+ if (attachData.textContent) {
1567
+ emitText = `[Attachment: ${attachData.filename} (${attachData.mime}) saved to ${attachData.filePath}]\n---\n${attachData.textContent}\n---\n\n${messageText}`;
1568
+ }
1569
+ else if (attachData.base64) {
1570
+ emitText = `[Image attachment: ${attachData.filename} saved to ${attachData.filePath}]\nUse your Read tool to view this image file.\n\n${messageText}`;
1571
+ }
1572
+ else {
1573
+ emitText = `[Attachment: ${attachData.filename} saved to ${attachData.filePath}]\n\n${messageText}`;
1574
+ }
1575
+ }
1576
+ // Emit to agent developer
1577
+ const metadata = {
1578
+ messageId: msgData.message_id,
1579
+ conversationId: convId,
1580
+ timestamp: msgData.created_at,
1581
+ topicId,
1582
+ attachment: attachData,
1583
+ spanId: msgData.span_id,
1584
+ parentSpanId: msgData.parent_span_id,
1585
+ messageType: msgData.message_type ?? "text",
1586
+ priority: msgData.priority ?? "normal",
1587
+ envelopeVersion: msgData.envelope_version ?? "1.0.0",
1588
+ };
1589
+ this.emit("message", emitText, metadata);
1590
+ this.config.onMessage?.(emitText, metadata);
1591
+ // Relay to sibling sessions as sync messages
1592
+ await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText, topicId);
1593
+ }
1594
+ // If type === "sync", agent ignores it (sync is for owner devices only)
1595
+ // Track last message timestamp for offline sync
1596
+ if (this._persisted) {
1597
+ this._persisted.lastMessageTimestamp = msgData.created_at;
1598
+ }
1599
+ // Persist all ratchet states after decrypt
1600
+ await this._persistState();
1601
+ }
1602
+ /**
1603
+ * Download an encrypted attachment blob, decrypt it, verify integrity,
1604
+ * and save the plaintext file to disk.
1605
+ */
1606
+ async _downloadAndDecryptAttachment(info) {
1607
+ const attachDir = join(this.config.dataDir, "attachments");
1608
+ await mkdir(attachDir, { recursive: true });
1609
+ // Download encrypted blob from API
1610
+ const url = `${this.config.apiUrl}${info.blobUrl}`;
1611
+ const res = await fetch(url, {
1612
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
1613
+ });
1614
+ if (!res.ok) {
1615
+ throw new Error(`Attachment download failed: ${res.status}`);
1616
+ }
1617
+ const buffer = await res.arrayBuffer();
1618
+ const encryptedData = new Uint8Array(buffer);
1619
+ // Verify integrity
1620
+ const digest = computeFileDigest(encryptedData);
1621
+ if (digest !== info.digest) {
1622
+ throw new Error("Attachment digest mismatch — possible tampering");
1623
+ }
1624
+ // Decrypt
1625
+ const fileKey = base64ToBytes(info.fileKey);
1626
+ const fileNonce = base64ToBytes(info.fileNonce);
1627
+ const decrypted = decryptFile(encryptedData, fileKey, fileNonce);
1628
+ // Save to disk
1629
+ const filePath = join(attachDir, info.filename);
1630
+ await writeFile(filePath, decrypted);
1631
+ console.log(`[SecureChannel] Attachment saved: ${filePath} (${decrypted.length} bytes)`);
1632
+ return { filePath, decrypted };
1633
+ }
1634
+ /**
1635
+ * Upload an attachment file: encrypt, upload to server, return metadata
1636
+ * for inclusion in the message envelope.
1637
+ */
1638
+ async _uploadAttachment(filePath, conversationId) {
1639
+ const data = await readFile(filePath);
1640
+ const plainData = new Uint8Array(data);
1641
+ const result = encryptFile(plainData);
1642
+ // Upload encrypted blob via multipart form
1643
+ const { Blob: NodeBlob, FormData: NodeFormData } = await import("node:buffer").then(() => globalThis);
1644
+ const formData = new FormData();
1645
+ formData.append("conversation_id", conversationId);
1646
+ formData.append("file", new Blob([result.encryptedData.buffer], { type: "application/octet-stream" }), "attachment.bin");
1647
+ const res = await fetch(`${this.config.apiUrl}/api/v1/attachments/upload`, {
1648
+ method: "POST",
1649
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
1650
+ body: formData,
1651
+ });
1652
+ if (!res.ok) {
1653
+ const detail = await res.text();
1654
+ throw new Error(`Attachment upload failed (${res.status}): ${detail}`);
1655
+ }
1656
+ const resp = await res.json();
1657
+ const filename = filePath.split("/").pop() || "file";
1658
+ return {
1659
+ blobId: resp.blob_id,
1660
+ blobUrl: resp.blob_url,
1661
+ fileKey: bytesToBase64(result.fileKey),
1662
+ fileNonce: bytesToBase64(result.fileNonce),
1663
+ digest: result.digest,
1664
+ filename,
1665
+ mime: "application/octet-stream",
1666
+ size: plainData.length,
1667
+ };
1668
+ }
1669
+ /**
1670
+ * Send a message with an attached file. Encrypts the file, uploads it,
1671
+ * then sends the envelope with attachment metadata via Double Ratchet.
1672
+ */
1673
+ async sendWithAttachment(plaintext, filePath, options) {
1674
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
1675
+ throw new Error("Channel is not ready");
1676
+ }
1677
+ const topicId = options?.topicId ?? this._persisted?.defaultTopicId;
1678
+ // Upload attachment using primary conversation for the blob
1679
+ const attachMeta = await this._uploadAttachment(filePath, this._primaryConversationId);
1680
+ // Build envelope
1681
+ const envelope = JSON.stringify({
1682
+ type: "message",
1683
+ text: plaintext,
1684
+ topicId,
1685
+ attachment: attachMeta,
1686
+ });
1687
+ // Store agent message in history
1688
+ this._appendHistory("agent", plaintext, topicId);
1689
+ const messageGroupId = randomUUID();
1690
+ for (const [convId, session] of this._sessions) {
1691
+ if (!session.activated)
1692
+ continue;
1693
+ const encrypted = session.ratchet.encrypt(envelope);
1694
+ const transport = encryptedMessageToTransport(encrypted);
1695
+ this._ws.send(JSON.stringify({
1696
+ event: "message",
1697
+ data: {
1698
+ conversation_id: convId,
1699
+ header_blob: transport.header_blob,
1700
+ ciphertext: transport.ciphertext,
1701
+ message_group_id: messageGroupId,
1702
+ topic_id: topicId,
1703
+ },
1704
+ }));
1705
+ }
1706
+ await this._persistState();
1707
+ }
1708
+ /**
1709
+ * Relay an owner's message to all sibling sessions as encrypted sync messages.
1710
+ * This allows all owner devices to see messages from any single device.
1711
+ */
1712
+ async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText, topicId) {
1713
+ if (!this._ws || this._sessions.size <= 1)
1714
+ return;
1715
+ const syncPayload = JSON.stringify({
1716
+ type: "sync",
1717
+ sender: senderOwnerDeviceId,
1718
+ text: messageText,
1719
+ ts: new Date().toISOString(),
1720
+ topicId,
1721
+ });
1722
+ for (const [siblingConvId, siblingSession] of this._sessions) {
1723
+ if (siblingConvId === sourceConvId)
1724
+ continue;
1725
+ // Don't relay to sessions where owner hasn't sent their first message yet
1726
+ if (!siblingSession.activated)
1727
+ continue;
1728
+ const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
1729
+ const syncTransport = encryptedMessageToTransport(syncEncrypted);
1730
+ this._ws.send(JSON.stringify({
1731
+ event: "message",
1732
+ data: {
1733
+ conversation_id: siblingConvId,
1734
+ header_blob: syncTransport.header_blob,
1735
+ ciphertext: syncTransport.ciphertext,
1736
+ },
1737
+ }));
1738
+ }
1739
+ }
1740
+ /**
1741
+ * Send stored message history to a newly-activated session.
1742
+ * Batches all history into a single encrypted message.
1743
+ */
1744
+ async _replayHistoryToSession(convId) {
1745
+ const session = this._sessions.get(convId);
1746
+ if (!session || !session.activated || !this._ws)
1747
+ return;
1748
+ const history = this._persisted?.messageHistory ?? [];
1749
+ if (history.length === 0) {
1750
+ console.log(`[SecureChannel] No history to replay for ${convId.slice(0, 8)}...`);
1751
+ return;
1752
+ }
1753
+ console.log(`[SecureChannel] Replaying ${history.length} messages to session ${convId.slice(0, 8)}...`);
1754
+ const replayPayload = JSON.stringify({
1755
+ type: "history_replay",
1756
+ messages: history,
1757
+ });
1758
+ const encrypted = session.ratchet.encrypt(replayPayload);
1759
+ const transport = encryptedMessageToTransport(encrypted);
1760
+ this._ws.send(JSON.stringify({
1761
+ event: "message",
1762
+ data: {
1763
+ conversation_id: convId,
1764
+ header_blob: transport.header_blob,
1765
+ ciphertext: transport.ciphertext,
1766
+ },
1767
+ }));
1768
+ }
1769
+ /**
1770
+ * Handle a device_linked event: a new owner device has joined.
1771
+ * Fetches the new device's public keys, performs X3DH, and initializes
1772
+ * a new ratchet session.
1773
+ */
1774
+ async _handleDeviceLinked(event) {
1775
+ console.log(`[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`);
1776
+ try {
1777
+ if (!event.owner_identity_public_key) {
1778
+ console.error(`[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`);
1779
+ return;
1780
+ }
1781
+ const identity = this._persisted.identityKeypair;
1782
+ const ephemeral = this._persisted.ephemeralKeypair;
1783
+ // Perform X3DH as responder with the new owner device
1784
+ const sharedSecret = performX3DH({
1785
+ myIdentityPrivate: hexToBytes(identity.privateKey),
1786
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
1787
+ theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
1788
+ theirEphemeralPublic: hexToBytes(event.owner_ephemeral_public_key ?? event.owner_identity_public_key),
1789
+ isInitiator: false,
1790
+ });
1791
+ // Initialize ratchet as receiver
1792
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
1793
+ publicKey: hexToBytes(identity.publicKey),
1794
+ privateKey: hexToBytes(identity.privateKey),
1795
+ keyType: "ed25519",
1796
+ });
1797
+ this._sessions.set(event.conversation_id, {
1798
+ ownerDeviceId: event.owner_device_id,
1799
+ ratchet,
1800
+ activated: false, // Wait for owner's first message
1801
+ });
1802
+ // Persist
1803
+ this._persisted.sessions[event.conversation_id] = {
1804
+ ownerDeviceId: event.owner_device_id,
1805
+ ratchetState: ratchet.serialize(),
1806
+ activated: false,
1807
+ };
1808
+ await this._persistState();
1809
+ console.log(`[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`);
1810
+ }
1811
+ catch (err) {
1812
+ console.error(`[SecureChannel] Failed to handle device_linked:`, err);
1813
+ this.emit("error", err);
1814
+ }
1815
+ }
1816
+ /**
1817
+ * Handle an incoming room message. Finds the pairwise conversation
1818
+ * for the sender, decrypts, and emits a room_message event.
1819
+ */
1820
+ async _handleRoomMessage(msgData) {
1821
+ // Don't decrypt our own messages
1822
+ if (msgData.sender_device_id === this._deviceId)
1823
+ return;
1824
+ const convId = msgData.conversation_id ??
1825
+ this._findConversationForSender(msgData.sender_device_id, msgData.room_id);
1826
+ if (!convId) {
1827
+ console.warn(`[SecureChannel] No conversation found for sender ${msgData.sender_device_id.slice(0, 8)}... in room ${msgData.room_id}`);
1828
+ return;
1829
+ }
1830
+ const session = this._sessions.get(convId);
1831
+ if (!session) {
1832
+ console.warn(`[SecureChannel] No session for room conv ${convId.slice(0, 8)}..., skipping`);
1833
+ return;
1834
+ }
1835
+ const encrypted = transportToEncryptedMessage({
1836
+ header_blob: msgData.header_blob,
1837
+ ciphertext: msgData.ciphertext,
1838
+ });
1839
+ const plaintext = session.ratchet.decrypt(encrypted);
1840
+ // Activate session on first received message
1841
+ if (!session.activated) {
1842
+ session.activated = true;
1843
+ console.log(`[SecureChannel] Room session ${convId.slice(0, 8)}... activated by first message`);
1844
+ }
1845
+ // ACK if message_id present
1846
+ if (msgData.message_id) {
1847
+ this._sendAck(msgData.message_id);
1848
+ }
1849
+ await this._persistState();
1850
+ const metadata = {
1851
+ messageId: msgData.message_id ?? "",
1852
+ conversationId: convId,
1853
+ timestamp: msgData.created_at ?? new Date().toISOString(),
1854
+ messageType: msgData.message_type ?? "text",
1855
+ };
1856
+ this.emit("room_message", {
1857
+ roomId: msgData.room_id,
1858
+ senderDeviceId: msgData.sender_device_id,
1859
+ plaintext,
1860
+ messageType: msgData.message_type ?? "text",
1861
+ timestamp: msgData.created_at ?? new Date().toISOString(),
1862
+ });
1863
+ this.config.onMessage?.(plaintext, metadata);
1864
+ }
1865
+ /**
1866
+ * Find the pairwise conversation ID for a given sender in a room.
1867
+ */
1868
+ _findConversationForSender(senderDeviceId, roomId) {
1869
+ const room = this._persisted?.rooms?.[roomId];
1870
+ if (!room)
1871
+ return null;
1872
+ for (const convId of room.conversationIds) {
1873
+ const session = this._sessions.get(convId);
1874
+ if (session && session.ownerDeviceId === senderDeviceId) {
1875
+ return convId;
1876
+ }
1877
+ }
1878
+ return null;
1879
+ }
1880
+ /**
1881
+ * Sync missed messages across ALL sessions.
1882
+ * For each conversation, fetches messages since last sync and decrypts.
1883
+ */
1884
+ /**
1885
+ * Paginated sync: fetch missed messages in pages of 200, up to 5 pages (1000 messages).
1886
+ * Tracks message IDs in _syncMessageIds to prevent duplicate processing from concurrent WS messages.
1887
+ */
1888
+ async _syncMissedMessages() {
1889
+ if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt)
1890
+ return;
1891
+ this._syncMessageIds = new Set();
1892
+ const MAX_PAGES = 5;
1893
+ const PAGE_SIZE = 200;
1894
+ let since = this._persisted.lastMessageTimestamp;
1895
+ let totalProcessed = 0;
1896
+ try {
1897
+ for (let page = 0; page < MAX_PAGES; page++) {
1898
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=${PAGE_SIZE}`;
1899
+ const res = await fetch(url, {
1900
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
1901
+ });
1902
+ if (!res.ok)
1903
+ break;
1904
+ const messages = await res.json();
1905
+ if (messages.length === 0)
1906
+ break;
1907
+ for (const msg of messages) {
1908
+ if (msg.sender_device_id === this._deviceId)
1909
+ continue;
1910
+ // Dedup: skip if already processed
1911
+ if (this._syncMessageIds.has(msg.id))
1912
+ continue;
1913
+ this._syncMessageIds.add(msg.id);
1914
+ const session = this._sessions.get(msg.conversation_id);
1915
+ if (!session) {
1916
+ console.warn(`[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`);
1917
+ continue;
1918
+ }
1919
+ try {
1920
+ const encrypted = transportToEncryptedMessage({
1921
+ header_blob: msg.header_blob,
1922
+ ciphertext: msg.ciphertext,
1923
+ });
1924
+ const plaintext = session.ratchet.decrypt(encrypted);
1925
+ this._sendAck(msg.id);
1926
+ if (!session.activated) {
1927
+ session.activated = true;
1928
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
1929
+ }
1930
+ let messageText;
1931
+ let messageType;
1932
+ try {
1933
+ const parsed = JSON.parse(plaintext);
1934
+ messageType = parsed.type || "message";
1935
+ messageText = parsed.text || plaintext;
1936
+ }
1937
+ catch {
1938
+ messageType = "message";
1939
+ messageText = plaintext;
1940
+ }
1941
+ if (messageType === "message") {
1942
+ const topicId = msg.topic_id;
1943
+ this._appendHistory("owner", messageText, topicId);
1944
+ const metadata = {
1945
+ messageId: msg.id,
1946
+ conversationId: msg.conversation_id,
1947
+ timestamp: msg.created_at,
1948
+ topicId,
1949
+ };
1950
+ this.emit("message", messageText, metadata);
1951
+ this.config.onMessage?.(messageText, metadata);
1952
+ }
1953
+ this._persisted.lastMessageTimestamp = msg.created_at;
1954
+ since = msg.created_at;
1955
+ totalProcessed++;
1956
+ }
1957
+ catch (err) {
1958
+ this.emit("error", err);
1959
+ break; // Ratchet desync -- stop processing
1960
+ }
1961
+ }
1962
+ await this._persistState();
1963
+ // If we got fewer than PAGE_SIZE, we've caught up
1964
+ if (messages.length < PAGE_SIZE)
1965
+ break;
1966
+ }
1967
+ if (totalProcessed > 0) {
1968
+ console.log(`[SecureChannel] Synced ${totalProcessed} missed messages`);
1969
+ }
1970
+ }
1971
+ catch {
1972
+ // Network error -- non-critical, will get messages via WS
1973
+ }
1974
+ this._syncMessageIds = null;
1975
+ }
1976
+ _sendAck(messageId) {
1977
+ this._pendingAcks.push(messageId);
1978
+ if (this._ackTimer)
1979
+ clearTimeout(this._ackTimer);
1980
+ this._ackTimer = setTimeout(() => this._flushAcks(), 500);
1981
+ }
1982
+ _flushAcks() {
1983
+ if (this._pendingAcks.length === 0 || !this._ws)
1984
+ return;
1985
+ const batch = this._pendingAcks.splice(0, 50);
1986
+ this._ws.send(JSON.stringify({ event: "ack", data: { message_ids: batch } }));
1987
+ }
1988
+ async _flushOutboundQueue() {
1989
+ const queue = this._persisted?.outboundQueue;
1990
+ if (!queue || queue.length === 0 || !this._ws)
1991
+ return;
1992
+ console.log(`[SecureChannel] Flushing ${queue.length} queued outbound messages`);
1993
+ const messages = queue.splice(0); // Take all, clear queue
1994
+ for (const msg of messages) {
1995
+ try {
1996
+ this._ws.send(JSON.stringify({
1997
+ event: "message",
1998
+ data: {
1999
+ conversation_id: msg.convId,
2000
+ header_blob: msg.headerBlob,
2001
+ ciphertext: msg.ciphertext,
2002
+ message_group_id: msg.messageGroupId,
2003
+ topic_id: msg.topicId,
2004
+ },
2005
+ }));
2006
+ }
2007
+ catch (err) {
2008
+ // Re-queue failed messages
2009
+ if (!this._persisted.outboundQueue) {
2010
+ this._persisted.outboundQueue = [];
2011
+ }
2012
+ this._persisted.outboundQueue.push(msg);
2013
+ console.warn(`[SecureChannel] Failed to flush message, re-queued: ${err}`);
2014
+ break; // Stop flushing on first failure
2015
+ }
2016
+ }
2017
+ await this._persistState();
2018
+ }
2019
+ _startPing(ws) {
2020
+ this._stopPing(); // Clear any existing timers
2021
+ this._lastServerMessage = Date.now();
2022
+ this._pingTimer = setInterval(() => {
2023
+ if (ws.readyState !== WebSocket.OPEN)
2024
+ return;
2025
+ const silence = Date.now() - this._lastServerMessage;
2026
+ if (silence > SecureChannel.SILENCE_TIMEOUT_MS) {
2027
+ console.log(`[SecureChannel] No server data for ${Math.round(silence / 1000)}s — reconnecting stale WebSocket`);
2028
+ ws.terminate(); // Forces close → _scheduleReconnect fires
2029
+ }
2030
+ }, SecureChannel.PING_INTERVAL_MS);
2031
+ }
2032
+ _stopPing() {
2033
+ if (this._pingTimer) {
2034
+ clearInterval(this._pingTimer);
2035
+ this._pingTimer = null;
2036
+ }
2037
+ }
2038
+ _startWakeDetector() {
2039
+ this._stopWakeDetector();
2040
+ this._lastWakeTick = Date.now();
2041
+ this._wakeDetectorTimer = setInterval(() => {
2042
+ const gap = Date.now() - this._lastWakeTick;
2043
+ this._lastWakeTick = Date.now();
2044
+ if (gap > 120_000 && this._ws) {
2045
+ console.log(`[SecureChannel] System wake detected (${Math.round(gap / 1000)}s gap) — forcing reconnect`);
2046
+ this._ws.terminate();
2047
+ }
2048
+ }, 10_000);
2049
+ }
2050
+ _stopWakeDetector() {
2051
+ if (this._wakeDetectorTimer) {
2052
+ clearInterval(this._wakeDetectorTimer);
2053
+ this._wakeDetectorTimer = null;
2054
+ }
2055
+ }
2056
+ _startPendingPoll() {
2057
+ this._stopPendingPoll();
2058
+ this._pendingPollTimer = setInterval(() => {
2059
+ this._checkPendingMessages();
2060
+ }, PENDING_POLL_INTERVAL_MS);
2061
+ }
2062
+ _stopPendingPoll() {
2063
+ if (this._pendingPollTimer) {
2064
+ clearInterval(this._pendingPollTimer);
2065
+ this._pendingPollTimer = null;
2066
+ }
2067
+ }
2068
+ async _checkPendingMessages() {
2069
+ // Don't poll while connecting (prevent hammering during reconnect)
2070
+ if (this._state === "connecting" || !this._deviceId || !this._deviceJwt)
2071
+ return;
2072
+ try {
2073
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/pending`;
2074
+ const resp = await fetch(url, {
2075
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
2076
+ signal: AbortSignal.timeout(10_000),
2077
+ });
2078
+ if (!resp.ok)
2079
+ return; // Silent fail — WS silence detector is backup
2080
+ const data = (await resp.json());
2081
+ if (data.pending > 0 && this._state !== "ready") {
2082
+ console.log(`[SecureChannel] Poll detected ${data.pending} pending messages — forcing reconnect`);
2083
+ if (this._ws) {
2084
+ this._ws.terminate();
2085
+ }
2086
+ else {
2087
+ this._scheduleReconnect();
2088
+ }
2089
+ }
2090
+ else if (data.pending > 0 && this._state === "ready") {
2091
+ // WS claims ready but messages are pending — possible half-open connection
2092
+ const silence = Date.now() - this._lastServerMessage;
2093
+ if (silence > 180_000) {
2094
+ console.log(`[SecureChannel] Poll: ${data.pending} pending + ${Math.round(silence / 1000)}s silence — forcing reconnect`);
2095
+ if (this._ws)
2096
+ this._ws.terminate();
2097
+ }
2098
+ }
2099
+ }
2100
+ catch {
2101
+ // Network error on poll — expected when offline, ignore silently
2102
+ }
2103
+ }
2104
+ _scheduleReconnect() {
2105
+ if (this._stopped)
2106
+ return;
2107
+ if (this._reconnectTimer)
2108
+ return; // Already scheduled
2109
+ const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, this._reconnectAttempt), RECONNECT_MAX_MS);
2110
+ this._reconnectAttempt++;
2111
+ console.log(`[SecureChannel] Scheduling reconnect in ${delay}ms (attempt ${this._reconnectAttempt})`);
2112
+ this._reconnectTimer = setTimeout(() => {
2113
+ this._reconnectTimer = null; // Clear BEFORE calling _connect so future disconnects can schedule new reconnects
2114
+ if (!this._stopped) {
2115
+ this._connect();
2116
+ }
2117
+ }, delay);
2118
+ }
2119
+ _setState(newState) {
2120
+ if (this._state === newState)
2121
+ return;
2122
+ this._state = newState;
2123
+ this.emit("state", newState);
2124
+ this.config.onStateChange?.(newState);
2125
+ // Start/stop polling fallback based on connection state
2126
+ if (newState === "disconnected" && !this._pollFallbackTimer && this._deviceJwt) {
2127
+ this._startPollFallback();
2128
+ }
2129
+ if (newState === "ready" || newState === "error" || this._stopped) {
2130
+ this._stopPollFallback();
2131
+ }
2132
+ }
2133
+ _startPollFallback() {
2134
+ this._stopPollFallback();
2135
+ console.log("[SecureChannel] Starting HTTP poll fallback (WS is down)");
2136
+ let interval = SecureChannel.POLL_FALLBACK_INTERVAL_MS;
2137
+ const poll = async () => {
2138
+ if (this._state === "ready" || this._stopped) {
2139
+ this._stopPollFallback();
2140
+ return;
2141
+ }
2142
+ try {
2143
+ const since = this._persisted?.lastMessageTimestamp;
2144
+ if (!since || !this._deviceJwt)
2145
+ return;
2146
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=200`;
2147
+ const res = await fetch(url, {
2148
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
2149
+ });
2150
+ if (!res.ok)
2151
+ return;
2152
+ const messages = await res.json();
2153
+ let foundMessages = false;
2154
+ for (const msg of messages) {
2155
+ if (msg.sender_device_id === this._deviceId)
2156
+ continue;
2157
+ const session = this._sessions.get(msg.conversation_id);
2158
+ if (!session)
2159
+ continue;
2160
+ try {
2161
+ const encrypted = transportToEncryptedMessage({
2162
+ header_blob: msg.header_blob,
2163
+ ciphertext: msg.ciphertext,
2164
+ });
2165
+ const plaintext = session.ratchet.decrypt(encrypted);
2166
+ if (!session.activated) {
2167
+ session.activated = true;
2168
+ }
2169
+ let messageText;
2170
+ let messageType;
2171
+ try {
2172
+ const parsed = JSON.parse(plaintext);
2173
+ messageType = parsed.type || "message";
2174
+ messageText = parsed.text || plaintext;
2175
+ }
2176
+ catch {
2177
+ messageType = "message";
2178
+ messageText = plaintext;
2179
+ }
2180
+ if (messageType === "message") {
2181
+ const topicId = msg.topic_id;
2182
+ this._appendHistory("owner", messageText, topicId);
2183
+ const metadata = {
2184
+ messageId: msg.id,
2185
+ conversationId: msg.conversation_id,
2186
+ timestamp: msg.created_at,
2187
+ topicId,
2188
+ };
2189
+ this.emit("message", messageText, metadata);
2190
+ this.config.onMessage?.(messageText, metadata);
2191
+ foundMessages = true;
2192
+ }
2193
+ this._persisted.lastMessageTimestamp = msg.created_at;
2194
+ }
2195
+ catch (err) {
2196
+ this.emit("error", err);
2197
+ break;
2198
+ }
2199
+ }
2200
+ if (messages.length > 0) {
2201
+ await this._persistState();
2202
+ }
2203
+ // Adaptive rate: faster when active, slower when idle
2204
+ const newInterval = foundMessages
2205
+ ? SecureChannel.POLL_FALLBACK_INTERVAL_MS
2206
+ : SecureChannel.POLL_FALLBACK_IDLE_MS;
2207
+ if (newInterval !== interval) {
2208
+ interval = newInterval;
2209
+ // Restart timer with new interval
2210
+ if (this._pollFallbackTimer) {
2211
+ clearInterval(this._pollFallbackTimer);
2212
+ this._pollFallbackTimer = setInterval(poll, interval);
2213
+ }
2214
+ }
2215
+ }
2216
+ catch {
2217
+ // Network error — try again next tick
2218
+ }
2219
+ };
2220
+ // Run first poll after a short delay, then on interval
2221
+ setTimeout(poll, 1_000);
2222
+ this._pollFallbackTimer = setInterval(poll, interval);
2223
+ }
2224
+ _stopPollFallback() {
2225
+ if (this._pollFallbackTimer) {
2226
+ clearInterval(this._pollFallbackTimer);
2227
+ this._pollFallbackTimer = null;
2228
+ console.log("[SecureChannel] Stopped HTTP poll fallback");
2229
+ }
2230
+ }
2231
+ _handleError(err) {
2232
+ this._setState("error");
2233
+ this.emit("error", err);
2234
+ }
2235
+ /**
2236
+ * Persist all ratchet session states to disk.
2237
+ * Syncs live ratchet states back into the persisted sessions map.
2238
+ */
2239
+ async _persistState() {
2240
+ if (!this._persisted)
2241
+ return;
2242
+ const hasOwnerSessions = this._sessions.size > 0;
2243
+ const hasA2AChannels = !!this._persisted.a2aChannels && Object.keys(this._persisted.a2aChannels).length > 0;
2244
+ if (!hasOwnerSessions && !hasA2AChannels)
2245
+ return;
2246
+ // Sync all live owner-agent ratchet states back to persisted
2247
+ for (const [convId, session] of this._sessions) {
2248
+ this._persisted.sessions[convId] = {
2249
+ ownerDeviceId: session.ownerDeviceId,
2250
+ ratchetState: session.ratchet.serialize(),
2251
+ activated: session.activated,
2252
+ };
2253
+ }
2254
+ await saveState(this.config.dataDir, this._persisted);
2255
+ }
2256
+ }
2257
+ //# sourceMappingURL=channel.js.map