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