clawmatrix 0.4.2 → 0.5.1
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/README.md +17 -21
- package/cli/bin/clawmatrix.mjs +300 -1
- package/package.json +8 -1
- package/src/acp-proxy.ts +122 -50
- package/src/{web.ts → api.ts} +646 -25
- package/src/audit.ts +37 -2
- package/src/auth.ts +5 -10
- package/src/automation.ts +625 -0
- package/src/cluster-service.ts +172 -16
- package/src/compat.ts +103 -0
- package/src/config.ts +75 -27
- package/src/connection.ts +225 -37
- package/src/crypto.ts +72 -5
- package/src/device-info.ts +21 -2
- package/src/file-transfer.ts +3 -2
- package/src/handoff.ts +90 -32
- package/src/health-tracker.ts +91 -356
- package/src/index.ts +421 -13
- package/src/kanban.ts +507 -0
- package/src/knowledge-sync.ts +158 -7
- package/src/local-tools.ts +65 -2
- package/src/log-replication.ts +198 -0
- package/src/model-proxy.ts +152 -60
- package/src/peer-approval.ts +3 -2
- package/src/peer-manager.ts +237 -47
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +86 -52
- package/src/store.ts +578 -0
- package/src/terminal.ts +17 -8
- package/src/tool-proxy.ts +6 -5
- package/src/tools/cluster-events.ts +6 -6
- package/src/tools/cluster-kanban.ts +345 -0
- package/src/tools/cluster-peers.ts +1 -1
- package/src/tools/cluster-query.ts +145 -0
- package/src/types.ts +95 -9
package/src/connection.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EventEmitter } from "
|
|
1
|
+
import { EventEmitter } from "eventemitter3";
|
|
2
2
|
import type {
|
|
3
3
|
AgentInfo,
|
|
4
4
|
AnyClusterFrame,
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
derivePerPeerSecret,
|
|
19
19
|
encryptBinary,
|
|
20
20
|
decryptBinary,
|
|
21
|
+
encryptBinaryRaw,
|
|
22
|
+
decryptBinaryRaw,
|
|
21
23
|
isBinaryEncrypted,
|
|
22
24
|
publicKeyToBase64,
|
|
23
25
|
base64ToPublicKey,
|
|
@@ -36,7 +38,7 @@ export type ConnectionRole = "inbound" | "outbound";
|
|
|
36
38
|
|
|
37
39
|
/** Minimal transport interface that works with both standard WebSocket and Bun's ServerWebSocket. */
|
|
38
40
|
export interface WsTransport {
|
|
39
|
-
send(data: string): void;
|
|
41
|
+
send(data: string | Buffer): void;
|
|
40
42
|
close(code?: number, reason?: string): void;
|
|
41
43
|
readonly readyState: number;
|
|
42
44
|
}
|
|
@@ -99,6 +101,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
99
101
|
private _remotePublicKey: Buffer | null = null;
|
|
100
102
|
private sessionKey: Buffer | null = null;
|
|
101
103
|
|
|
104
|
+
// ── Frame batching (coalesce small frames within a window) ─────
|
|
105
|
+
private pendingBatch: Buffer[] = [];
|
|
106
|
+
private batchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
107
|
+
private batchBytes = 0;
|
|
108
|
+
static readonly BATCH_WINDOW = 5; // ms
|
|
109
|
+
static readonly BATCH_MAX_FRAMES = 10;
|
|
110
|
+
static readonly BATCH_MAX_BYTES = 65536; // 64KB
|
|
111
|
+
|
|
102
112
|
constructor(
|
|
103
113
|
transport: WsTransport,
|
|
104
114
|
role: ConnectionRole,
|
|
@@ -174,69 +184,220 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
174
184
|
send(frame: ClusterFrame | AnyClusterFrame) {
|
|
175
185
|
if (this.closed) return;
|
|
176
186
|
if (this.sessionKey) {
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
this.enqueueBatch(encryptBinaryRaw(this.sessionKey, JSON.stringify(frame), this.compressionEnabled));
|
|
188
|
+
} else {
|
|
189
|
+
this.sendRaw(frame);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Send a frame immediately, bypassing the batch queue. */
|
|
194
|
+
sendDirect(frame: ClusterFrame | AnyClusterFrame) {
|
|
195
|
+
if (this.closed) return;
|
|
196
|
+
if (this.sessionKey) {
|
|
197
|
+
this.sendRaw(encryptBinaryRaw(this.sessionKey, JSON.stringify(frame), false));
|
|
179
198
|
} else {
|
|
180
199
|
this.sendRaw(frame);
|
|
181
200
|
}
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
/** Send
|
|
203
|
+
/** Send dummy traffic directly (bypasses batch queue). */
|
|
204
|
+
private sendDummy() {
|
|
205
|
+
if (this.closed || !this.sessionKey) return;
|
|
206
|
+
this.sendRaw(encryptBinaryRaw(this.sessionKey, JSON.stringify({ type: "_d", from: "", timestamp: 0 }), false));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Send raw data. Buffers sent as binary frames; strings as-is; objects JSON-encoded. */
|
|
185
210
|
private sendRaw(data: unknown) {
|
|
186
211
|
if (this.transport.readyState === WebSocket.OPEN) {
|
|
187
|
-
|
|
212
|
+
if (Buffer.isBuffer(data)) {
|
|
213
|
+
this.transport.send(data);
|
|
214
|
+
} else {
|
|
215
|
+
this.transport.send(typeof data === "string" ? data : JSON.stringify(data));
|
|
216
|
+
}
|
|
188
217
|
}
|
|
189
218
|
}
|
|
190
219
|
|
|
220
|
+
// ── Batch coalescing ──────────────────────────────────────────
|
|
221
|
+
private enqueueBatch(buf: Buffer) {
|
|
222
|
+
this.pendingBatch.push(buf);
|
|
223
|
+
this.batchBytes += buf.length;
|
|
224
|
+
|
|
225
|
+
if (this.pendingBatch.length >= Connection.BATCH_MAX_FRAMES ||
|
|
226
|
+
this.batchBytes >= Connection.BATCH_MAX_BYTES) {
|
|
227
|
+
this.flushBatch();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!this.batchTimer) {
|
|
232
|
+
this.batchTimer = setTimeout(() => this.flushBatch(), Connection.BATCH_WINDOW);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private flushBatch() {
|
|
237
|
+
if (this.batchTimer) {
|
|
238
|
+
clearTimeout(this.batchTimer);
|
|
239
|
+
this.batchTimer = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const frames = this.pendingBatch;
|
|
243
|
+
this.pendingBatch = [];
|
|
244
|
+
this.batchBytes = 0;
|
|
245
|
+
|
|
246
|
+
if (frames.length === 0) return;
|
|
247
|
+
|
|
248
|
+
if (frames.length === 1) {
|
|
249
|
+
this.sendRaw(frames[0]);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Multi-frame batch: [uint16 count][uint32 len1][frame1][uint32 len2][frame2]...
|
|
254
|
+
let totalSize = 2; // count header
|
|
255
|
+
for (const f of frames) totalSize += 4 + f.length;
|
|
256
|
+
const batch = Buffer.alloc(totalSize);
|
|
257
|
+
batch.writeUInt16BE(frames.length, 0);
|
|
258
|
+
let offset = 2;
|
|
259
|
+
for (const f of frames) {
|
|
260
|
+
batch.writeUInt32BE(f.length, offset);
|
|
261
|
+
offset += 4;
|
|
262
|
+
f.copy(batch, offset);
|
|
263
|
+
offset += f.length;
|
|
264
|
+
}
|
|
265
|
+
this.sendRaw(batch);
|
|
266
|
+
}
|
|
267
|
+
|
|
191
268
|
// ── Message dispatch ───────────────────────────────────────────
|
|
192
269
|
private async onRawMessage(data: unknown) {
|
|
193
|
-
const str = typeof data === "string" ? data : String(data);
|
|
194
|
-
if (!str.length) return;
|
|
195
270
|
this.lastReceivedAt = Date.now();
|
|
196
271
|
|
|
272
|
+
// Normalize non-Buffer binary types to Buffer.
|
|
273
|
+
// Node.js 24+'s built-in WebSocket (undici) delivers binary frames as Blob
|
|
274
|
+
// (binaryType "blob") or ArrayBuffer (binaryType "arraybuffer").
|
|
275
|
+
// The ws package delivers Buffer (binaryType "nodebuffer").
|
|
276
|
+
if (data instanceof ArrayBuffer) {
|
|
277
|
+
data = Buffer.from(data);
|
|
278
|
+
} else if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
279
|
+
data = Buffer.from(await data.arrayBuffer());
|
|
280
|
+
}
|
|
281
|
+
|
|
197
282
|
let frame: AnyClusterFrame | undefined;
|
|
198
283
|
|
|
199
|
-
// Binary
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
284
|
+
// Binary frame (Buffer) — decrypt directly without base64
|
|
285
|
+
if (Buffer.isBuffer(data)) {
|
|
286
|
+
if (data.length === 0) return;
|
|
287
|
+
|
|
288
|
+
// Check for batch format: first 2 bytes = frame count (2..BATCH_MAX_FRAMES).
|
|
289
|
+
// Validate by pre-scanning all length headers to confirm total matches data.length.
|
|
290
|
+
// This prevents false positives when a single encrypted frame's flags+IV
|
|
291
|
+
// happen to look like a valid batch header (e.g. flags=0x00, IV[0]=0x03).
|
|
292
|
+
if (data.length >= 6 && this.sessionKey) {
|
|
293
|
+
const count = data.readUInt16BE(0);
|
|
294
|
+
if (count >= 2 && count <= Connection.BATCH_MAX_FRAMES) {
|
|
295
|
+
// Pre-scan: verify all frame lengths add up exactly
|
|
296
|
+
let scanOffset = 2;
|
|
297
|
+
let valid = true;
|
|
298
|
+
for (let i = 0; i < count; i++) {
|
|
299
|
+
if (scanOffset + 4 > data.length) { valid = false; break; }
|
|
300
|
+
const len = data.readUInt32BE(scanOffset);
|
|
301
|
+
scanOffset += 4 + len;
|
|
302
|
+
if (scanOffset > data.length) { valid = false; break; }
|
|
303
|
+
}
|
|
304
|
+
if (valid && scanOffset === data.length) {
|
|
305
|
+
// Confirmed batch — unpack frames
|
|
306
|
+
let offset = 2;
|
|
307
|
+
for (let i = 0; i < count; i++) {
|
|
308
|
+
const len = data.readUInt32BE(offset);
|
|
309
|
+
offset += 4;
|
|
310
|
+
const frameBuf = data.subarray(offset, offset + len);
|
|
311
|
+
offset += len;
|
|
312
|
+
this.processSingleBinaryFrame(frameBuf);
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Single binary frame
|
|
320
|
+
if (this.sessionKey) {
|
|
321
|
+
this.processSingleBinaryFrame(data);
|
|
205
322
|
return;
|
|
323
|
+
} else {
|
|
324
|
+
// Binary frame without session key — try as UTF-8 JSON
|
|
325
|
+
try {
|
|
326
|
+
frame = JSON.parse(data.toString());
|
|
327
|
+
} catch {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
206
330
|
}
|
|
207
|
-
// Discard dummy traffic
|
|
208
|
-
if ((frame as any).type === "_d") return;
|
|
209
331
|
}
|
|
210
332
|
|
|
211
|
-
// JSON: handshake messages or plaintext frames
|
|
212
333
|
if (!frame) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
334
|
+
const str = typeof data === "string" ? data : String(data);
|
|
335
|
+
if (!str.length) return;
|
|
336
|
+
|
|
337
|
+
// Binary encrypted envelope (base64 text, for backward compat with old nodes)
|
|
338
|
+
if (this.sessionKey && isBinaryEncrypted(str)) {
|
|
339
|
+
try {
|
|
340
|
+
// Legacy base64 path always used JSON strings
|
|
341
|
+
frame = JSON.parse(decryptBinary(this.sessionKey, str));
|
|
342
|
+
} catch (err) {
|
|
343
|
+
debug("e2ee", `Frame decryption failed: ${err}`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Discard dummy traffic
|
|
347
|
+
if ((frame as any).type === "_d") return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// JSON: handshake messages or plaintext frames
|
|
351
|
+
if (!frame) {
|
|
352
|
+
try {
|
|
353
|
+
frame = JSON.parse(str);
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
217
357
|
}
|
|
218
358
|
}
|
|
219
359
|
|
|
360
|
+
this.dispatchFrame(frame!);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Decrypt and dispatch a single binary encrypted frame. */
|
|
364
|
+
private processSingleBinaryFrame(buf: Buffer) {
|
|
365
|
+
try {
|
|
366
|
+
const frame = JSON.parse(decryptBinaryRaw(this.sessionKey!, buf));
|
|
367
|
+
// Discard dummy traffic
|
|
368
|
+
if ((frame as any).type === "_d") return;
|
|
369
|
+
this.dispatchFrame(frame);
|
|
370
|
+
} catch {
|
|
371
|
+
// Fallback: Buffer may contain base64 text (e.g. ws package delivered a text frame as Buffer)
|
|
372
|
+
try {
|
|
373
|
+
const str = buf.toString();
|
|
374
|
+
if (isBinaryEncrypted(str)) {
|
|
375
|
+
const frame = JSON.parse(decryptBinary(this.sessionKey!, str));
|
|
376
|
+
if ((frame as any).type === "_d") return;
|
|
377
|
+
this.dispatchFrame(frame);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
} catch { /* ignore */ }
|
|
381
|
+
debug("e2ee", `Frame decryption failed (tried both binary and base64)`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Dispatch a fully parsed frame (post-decryption). */
|
|
386
|
+
private dispatchFrame(frame: AnyClusterFrame) {
|
|
220
387
|
if (!this.authenticated) {
|
|
221
|
-
|
|
388
|
+
this.handleAuthMessage(frame);
|
|
222
389
|
return;
|
|
223
390
|
}
|
|
224
391
|
|
|
225
|
-
// Approval pending: HMAC passed but auth_ok not yet sent.
|
|
226
|
-
// Only allow ping/pong — drop all business frames.
|
|
227
392
|
if (!this.authOkSent) {
|
|
228
|
-
if (frame
|
|
393
|
+
if (frame.type !== "ping" && frame.type !== "pong") return;
|
|
229
394
|
}
|
|
230
395
|
|
|
231
|
-
if (frame
|
|
232
|
-
this.send({
|
|
233
|
-
type: "pong",
|
|
234
|
-
from: this.nodeId,
|
|
235
|
-
timestamp: Date.now(),
|
|
236
|
-
} as AnyClusterFrame);
|
|
396
|
+
if (frame.type === "ping") {
|
|
397
|
+
this.send({ type: "pong", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
|
|
237
398
|
return;
|
|
238
399
|
}
|
|
239
|
-
if (frame
|
|
400
|
+
if (frame.type === "pong") {
|
|
240
401
|
this.missedPongs = 0;
|
|
241
402
|
if (this.lastPingSentAt > 0) {
|
|
242
403
|
const rtt = Date.now() - this.lastPingSentAt;
|
|
@@ -246,7 +407,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
246
407
|
return;
|
|
247
408
|
}
|
|
248
409
|
|
|
249
|
-
this.emit("message", frame
|
|
410
|
+
this.emit("message", frame);
|
|
250
411
|
}
|
|
251
412
|
|
|
252
413
|
private async handleAuthMessage(frame: any) {
|
|
@@ -296,7 +457,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
296
457
|
// Signal the outbound that auth is pending so it extends its timer
|
|
297
458
|
// instead of timing out after AUTH_TIMEOUT (10s).
|
|
298
459
|
if (this.sessionKey) {
|
|
299
|
-
|
|
460
|
+
// Send immediately (bypass batch) so client gets it as a standalone frame
|
|
461
|
+
const pending = { type: "auth_pending", from: this.nodeId, timestamp: Date.now() };
|
|
462
|
+
this.sendRaw(encryptBinaryRaw(this.sessionKey, JSON.stringify(pending), false));
|
|
300
463
|
} else {
|
|
301
464
|
this.sendRaw({ p: 1 });
|
|
302
465
|
}
|
|
@@ -453,7 +616,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
453
616
|
} as AuthOk;
|
|
454
617
|
|
|
455
618
|
if (this.sessionKey) {
|
|
456
|
-
|
|
619
|
+
// Send auth_ok immediately (bypass batch queue) so the client
|
|
620
|
+
// receives it as a standalone binary frame, not mixed into a batch.
|
|
621
|
+
this.sendRaw(encryptBinaryRaw(this.sessionKey, JSON.stringify(authOk), false));
|
|
457
622
|
} else {
|
|
458
623
|
this.sendRaw(authOk);
|
|
459
624
|
}
|
|
@@ -488,8 +653,19 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
488
653
|
this.scheduleHeartbeatTick();
|
|
489
654
|
}
|
|
490
655
|
|
|
656
|
+
/** Compute adaptive heartbeat interval based on measured latency. */
|
|
657
|
+
private adaptiveHeartbeatInterval(): number {
|
|
658
|
+
if (this.latencyMs > 0) {
|
|
659
|
+
// Low-latency (50ms) → 6s, high-latency (3s) → 20s
|
|
660
|
+
const base = Math.max(6_000, Math.min(this.latencyMs * 8, 20_000));
|
|
661
|
+
return base + Math.random() * 3_000;
|
|
662
|
+
}
|
|
663
|
+
// No latency measured yet — use default
|
|
664
|
+
return HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
|
|
665
|
+
}
|
|
666
|
+
|
|
491
667
|
private scheduleHeartbeatTick() {
|
|
492
|
-
const interval =
|
|
668
|
+
const interval = this.adaptiveHeartbeatInterval();
|
|
493
669
|
this.heartbeatTimer = setTimeout(this.heartbeatTick, interval);
|
|
494
670
|
}
|
|
495
671
|
|
|
@@ -505,6 +681,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
505
681
|
return;
|
|
506
682
|
}
|
|
507
683
|
|
|
684
|
+
// Skip ping if recent traffic proves the connection is alive
|
|
685
|
+
const currentInterval = this.adaptiveHeartbeatInterval();
|
|
686
|
+
const timeSinceReceive = Date.now() - this.lastReceivedAt;
|
|
687
|
+
if (this.lastReceivedAt > 0 && timeSinceReceive < currentInterval * 0.5) {
|
|
688
|
+
this.missedPongs = 0;
|
|
689
|
+
this.scheduleHeartbeatTick();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
508
693
|
// Increment before checking: this ping is about to be sent and
|
|
509
694
|
// counts as outstanding until a pong arrives.
|
|
510
695
|
this.missedPongs++;
|
|
@@ -551,13 +736,16 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
551
736
|
|
|
552
737
|
private dummyTick = () => {
|
|
553
738
|
if (this.closed || !this.sessionKey) return;
|
|
554
|
-
|
|
739
|
+
// Dummy traffic bypasses batch queue — sent directly
|
|
740
|
+
this.sendDummy();
|
|
555
741
|
this.scheduleDummyTick();
|
|
556
742
|
};
|
|
557
743
|
|
|
558
744
|
// ── Cleanup ────────────────────────────────────────────────────
|
|
559
745
|
close(code = 1000, reason = "normal") {
|
|
560
746
|
if (this.closed) return;
|
|
747
|
+
// Flush any pending batch before closing
|
|
748
|
+
this.flushBatch();
|
|
561
749
|
this.closed = true;
|
|
562
750
|
this.clearAuthTimer();
|
|
563
751
|
if (this.heartbeatTimer) {
|
package/src/crypto.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
hkdfSync,
|
|
16
16
|
randomBytes,
|
|
17
17
|
} from "node:crypto";
|
|
18
|
-
import {
|
|
18
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
19
19
|
|
|
20
20
|
// ── Key exchange ──────────────────────────────────────────────────
|
|
21
21
|
|
|
@@ -93,13 +93,13 @@ export function deriveSessionKey(
|
|
|
93
93
|
export function encryptBinary(
|
|
94
94
|
sessionKey: Buffer,
|
|
95
95
|
plaintext: string,
|
|
96
|
-
compress: boolean =
|
|
96
|
+
compress: boolean = true,
|
|
97
97
|
): string {
|
|
98
98
|
let data: Buffer;
|
|
99
99
|
let flags = 0;
|
|
100
100
|
|
|
101
|
-
if (compress && plaintext.length >
|
|
102
|
-
const deflated =
|
|
101
|
+
if (compress && plaintext.length > 1024) {
|
|
102
|
+
const deflated = deflateRawSync(Buffer.from(plaintext));
|
|
103
103
|
if (deflated.length < plaintext.length * 0.9) {
|
|
104
104
|
data = deflated;
|
|
105
105
|
flags |= 0x01;
|
|
@@ -147,7 +147,7 @@ export function decryptBinary(
|
|
|
147
147
|
const data = decrypted.subarray(4, 4 + actualLen);
|
|
148
148
|
|
|
149
149
|
if (flags & 0x01) {
|
|
150
|
-
return
|
|
150
|
+
return inflateRawSync(data).toString();
|
|
151
151
|
}
|
|
152
152
|
return data.toString();
|
|
153
153
|
}
|
|
@@ -157,6 +157,73 @@ export function isBinaryEncrypted(s: string): boolean {
|
|
|
157
157
|
return s.length > 0 && s[0] !== "{" && s[0] !== "[";
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// ── Binary raw (Buffer) variants — skip base64 for WS binary frames ──
|
|
161
|
+
|
|
162
|
+
/** Encrypt plaintext to a raw Buffer (no base64). For sending as WS binary frame.
|
|
163
|
+
* Same logic as encryptBinary but returns Buffer directly → ~33% smaller on wire. */
|
|
164
|
+
export function encryptBinaryRaw(
|
|
165
|
+
sessionKey: Buffer,
|
|
166
|
+
plaintext: string,
|
|
167
|
+
compress: boolean = true,
|
|
168
|
+
): Buffer {
|
|
169
|
+
let data: Buffer;
|
|
170
|
+
let flags = 0;
|
|
171
|
+
|
|
172
|
+
if (compress && plaintext.length > 1024) {
|
|
173
|
+
const deflated = deflateRawSync(Buffer.from(plaintext));
|
|
174
|
+
if (deflated.length < plaintext.length * 0.9) {
|
|
175
|
+
data = deflated;
|
|
176
|
+
flags |= 0x01;
|
|
177
|
+
} else {
|
|
178
|
+
data = Buffer.from(plaintext);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
data = Buffer.from(plaintext);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Random padding: 4-byte length prefix + data + random padding
|
|
185
|
+
const padLen = 16 + (randomBytes(1)[0]! % 48);
|
|
186
|
+
const lenBuf = Buffer.alloc(4);
|
|
187
|
+
lenBuf.writeUInt32BE(data.length, 0);
|
|
188
|
+
const padded = Buffer.concat([lenBuf, data, randomBytes(padLen)]);
|
|
189
|
+
|
|
190
|
+
const iv = randomBytes(12);
|
|
191
|
+
const cipher = createCipheriv("aes-256-gcm", sessionKey, iv);
|
|
192
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
193
|
+
const tag = cipher.getAuthTag();
|
|
194
|
+
|
|
195
|
+
return Buffer.concat([Buffer.from([flags]), iv, encrypted, tag]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Decrypt a raw Buffer envelope (no base64). Throws on auth failure. */
|
|
199
|
+
export function decryptBinaryRaw(
|
|
200
|
+
sessionKey: Buffer,
|
|
201
|
+
buf: Buffer,
|
|
202
|
+
): string {
|
|
203
|
+
if (buf.length < 1 + 12 + 16) throw new Error("Encrypted data too short");
|
|
204
|
+
|
|
205
|
+
const flags = buf[0]!;
|
|
206
|
+
const iv = buf.subarray(1, 13);
|
|
207
|
+
const rest = buf.subarray(13);
|
|
208
|
+
const ciphertext = rest.subarray(0, rest.length - 16);
|
|
209
|
+
const tag = rest.subarray(rest.length - 16);
|
|
210
|
+
|
|
211
|
+
const decipher = createDecipheriv("aes-256-gcm", sessionKey, iv);
|
|
212
|
+
decipher.setAuthTag(tag);
|
|
213
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
214
|
+
|
|
215
|
+
// Strip length-prefixed padding
|
|
216
|
+
const actualLen = decrypted.readUInt32BE(0);
|
|
217
|
+
const data = decrypted.subarray(4, 4 + actualLen);
|
|
218
|
+
|
|
219
|
+
if (flags & 0x01) {
|
|
220
|
+
return inflateRawSync(data).toString();
|
|
221
|
+
}
|
|
222
|
+
return data.toString();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
160
227
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
161
228
|
|
|
162
229
|
/** Encode a raw public key to base64 for wire transmission. */
|
package/src/device-info.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Collect local device/system information using Node.js os module.
|
|
3
3
|
*/
|
|
4
|
-
import { arch, cpus, hostname, totalmem, type, release } from "node:os";
|
|
4
|
+
import { arch, cpus, hostname, totalmem, type, release, homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import type { DeviceInfo } from "./types.ts";
|
|
@@ -33,8 +33,25 @@ function resolveOpenclawVersion(hint?: string): string | undefined {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Try to read agents.defaults.workspace from OpenClaw config file (~/.openclaw/openclaw.json). */
|
|
37
|
+
function resolveWorkspace(openclawConfig?: Record<string, unknown>): string | undefined {
|
|
38
|
+
// 1. Try from the passed-in config object
|
|
39
|
+
const agentsDefaults = (openclawConfig?.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined;
|
|
40
|
+
if (typeof agentsDefaults?.workspace === "string") return agentsDefaults.workspace;
|
|
41
|
+
|
|
42
|
+
// 2. Fallback: read ~/.openclaw/openclaw.json directly
|
|
43
|
+
try {
|
|
44
|
+
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
|
45
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
46
|
+
const ws = raw?.agents?.defaults?.workspace;
|
|
47
|
+
if (typeof ws === "string") return ws;
|
|
48
|
+
} catch { /* config not found or invalid */ }
|
|
49
|
+
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
/** Collect device info once at startup. */
|
|
37
|
-
export function collectDeviceInfo(openclawVersion?: string): DeviceInfo {
|
|
54
|
+
export function collectDeviceInfo(openclawVersion?: string, openclawConfig?: Record<string, unknown>): DeviceInfo {
|
|
38
55
|
const cpuList = cpus();
|
|
39
56
|
return {
|
|
40
57
|
os: `${type()} ${release()}`,
|
|
@@ -44,5 +61,7 @@ export function collectDeviceInfo(openclawVersion?: string): DeviceInfo {
|
|
|
44
61
|
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
|
45
62
|
hostname: hostname(),
|
|
46
63
|
openclawVersion: resolveOpenclawVersion(openclawVersion),
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
workspace: resolveWorkspace(openclawConfig),
|
|
47
66
|
};
|
|
48
67
|
}
|
package/src/file-transfer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile, writeFile, stat, mkdir, lstat, realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { nanoid } from "nanoid";
|
|
4
5
|
import { debug } from "./debug.ts";
|
|
5
6
|
import type { PeerManager } from "./peer-manager.ts";
|
|
6
7
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
@@ -114,7 +115,7 @@ export class FileTransferManager {
|
|
|
114
115
|
|
|
115
116
|
const checksum = createHash("sha256").update(fileData).digest("hex");
|
|
116
117
|
const totalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
|
|
117
|
-
const sessionId =
|
|
118
|
+
const sessionId = nanoid();
|
|
118
119
|
|
|
119
120
|
return new Promise<TransferResult>((resolve, reject) => {
|
|
120
121
|
const timer = this.createTimer(sessionId, "pending");
|
|
@@ -160,7 +161,7 @@ export class FileTransferManager {
|
|
|
160
161
|
const resolvedPath = path.resolve(localPath);
|
|
161
162
|
await this.validatePath(resolvedPath);
|
|
162
163
|
|
|
163
|
-
const sessionId =
|
|
164
|
+
const sessionId = nanoid();
|
|
164
165
|
|
|
165
166
|
return new Promise<TransferResult>((resolve, reject) => {
|
|
166
167
|
const timer = this.createTimer(sessionId, "pending");
|