@agentvault/agentvault 0.9.8 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/install-plugin.test.d.ts +2 -0
- package/dist/__tests__/install-plugin.test.d.ts.map +1 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/cli.js +16 -17
- package/dist/cli.js.map +3 -3
- package/dist/index.js +16 -17
- package/dist/index.js.map +3 -3
- package/dist/openclaw-entry.d.ts.map +1 -1
- package/dist/openclaw-entry.js +76 -18
- package/dist/openclaw-entry.js.map +2 -2
- package/package.json +1 -1
- package/dist/channel.js +0 -2257
- package/dist/channel.js.map +0 -1
- package/dist/crypto-helpers.js +0 -4
- package/dist/crypto-helpers.js.map +0 -1
- package/dist/openclaw-plugin.js +0 -222
- package/dist/openclaw-plugin.js.map +0 -1
- package/dist/setup.js +0 -329
- package/dist/setup.js.map +0 -1
- package/dist/state.js +0 -61
- package/dist/state.js.map +0 -1
- package/dist/transport.js +0 -43
- package/dist/transport.js.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
package/dist/channel.js
DELETED
|
@@ -1,2257 +0,0 @@
|
|
|
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
|