clawmatrix 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +215 -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 +236 -44
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +85 -51
- 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,210 @@ 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
|
-
this.sendRaw(encryptBinary(this.sessionKey, JSON.stringify(frame), this.compressionEnabled));
|
|
187
|
+
this.enqueueBatch(encryptBinaryRaw(this.sessionKey, JSON.stringify(frame), this.compressionEnabled));
|
|
179
188
|
} else {
|
|
180
189
|
this.sendRaw(frame);
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
/** Send
|
|
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));
|
|
198
|
+
} else {
|
|
199
|
+
this.sendRaw(frame);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
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
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
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;
|
|
188
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);
|
|
189
266
|
}
|
|
190
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
|
|
|
197
272
|
let frame: AnyClusterFrame | undefined;
|
|
198
273
|
|
|
199
|
-
// Binary
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
274
|
+
// Binary frame (Buffer) — decrypt directly without base64
|
|
275
|
+
if (Buffer.isBuffer(data)) {
|
|
276
|
+
if (data.length === 0) return;
|
|
277
|
+
|
|
278
|
+
// Check for batch format: first 2 bytes = frame count (2..BATCH_MAX_FRAMES).
|
|
279
|
+
// Validate by pre-scanning all length headers to confirm total matches data.length.
|
|
280
|
+
// This prevents false positives when a single encrypted frame's flags+IV
|
|
281
|
+
// happen to look like a valid batch header (e.g. flags=0x00, IV[0]=0x03).
|
|
282
|
+
if (data.length >= 6 && this.sessionKey) {
|
|
283
|
+
const count = data.readUInt16BE(0);
|
|
284
|
+
if (count >= 2 && count <= Connection.BATCH_MAX_FRAMES) {
|
|
285
|
+
// Pre-scan: verify all frame lengths add up exactly
|
|
286
|
+
let scanOffset = 2;
|
|
287
|
+
let valid = true;
|
|
288
|
+
for (let i = 0; i < count; i++) {
|
|
289
|
+
if (scanOffset + 4 > data.length) { valid = false; break; }
|
|
290
|
+
const len = data.readUInt32BE(scanOffset);
|
|
291
|
+
scanOffset += 4 + len;
|
|
292
|
+
if (scanOffset > data.length) { valid = false; break; }
|
|
293
|
+
}
|
|
294
|
+
if (valid && scanOffset === data.length) {
|
|
295
|
+
// Confirmed batch — unpack frames
|
|
296
|
+
let offset = 2;
|
|
297
|
+
for (let i = 0; i < count; i++) {
|
|
298
|
+
const len = data.readUInt32BE(offset);
|
|
299
|
+
offset += 4;
|
|
300
|
+
const frameBuf = data.subarray(offset, offset + len);
|
|
301
|
+
offset += len;
|
|
302
|
+
this.processSingleBinaryFrame(frameBuf);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Single binary frame
|
|
310
|
+
if (this.sessionKey) {
|
|
311
|
+
this.processSingleBinaryFrame(data);
|
|
205
312
|
return;
|
|
313
|
+
} else {
|
|
314
|
+
// Binary frame without session key — try as UTF-8 JSON
|
|
315
|
+
try {
|
|
316
|
+
frame = JSON.parse(data.toString());
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
206
320
|
}
|
|
207
|
-
// Discard dummy traffic
|
|
208
|
-
if ((frame as any).type === "_d") return;
|
|
209
321
|
}
|
|
210
322
|
|
|
211
|
-
// JSON: handshake messages or plaintext frames
|
|
212
323
|
if (!frame) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
324
|
+
const str = typeof data === "string" ? data : String(data);
|
|
325
|
+
if (!str.length) return;
|
|
326
|
+
|
|
327
|
+
// Binary encrypted envelope (base64 text, for backward compat with old nodes)
|
|
328
|
+
if (this.sessionKey && isBinaryEncrypted(str)) {
|
|
329
|
+
try {
|
|
330
|
+
// Legacy base64 path always used JSON strings
|
|
331
|
+
frame = JSON.parse(decryptBinary(this.sessionKey, str));
|
|
332
|
+
} catch (err) {
|
|
333
|
+
debug("e2ee", `Frame decryption failed: ${err}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Discard dummy traffic
|
|
337
|
+
if ((frame as any).type === "_d") return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// JSON: handshake messages or plaintext frames
|
|
341
|
+
if (!frame) {
|
|
342
|
+
try {
|
|
343
|
+
frame = JSON.parse(str);
|
|
344
|
+
} catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
217
347
|
}
|
|
218
348
|
}
|
|
219
349
|
|
|
350
|
+
this.dispatchFrame(frame!);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Decrypt and dispatch a single binary encrypted frame. */
|
|
354
|
+
private processSingleBinaryFrame(buf: Buffer) {
|
|
355
|
+
try {
|
|
356
|
+
const frame = JSON.parse(decryptBinaryRaw(this.sessionKey!, buf));
|
|
357
|
+
// Discard dummy traffic
|
|
358
|
+
if ((frame as any).type === "_d") return;
|
|
359
|
+
this.dispatchFrame(frame);
|
|
360
|
+
} catch {
|
|
361
|
+
// Fallback: Buffer may contain base64 text (e.g. ws package delivered a text frame as Buffer)
|
|
362
|
+
try {
|
|
363
|
+
const str = buf.toString();
|
|
364
|
+
if (isBinaryEncrypted(str)) {
|
|
365
|
+
const frame = JSON.parse(decryptBinary(this.sessionKey!, str));
|
|
366
|
+
if ((frame as any).type === "_d") return;
|
|
367
|
+
this.dispatchFrame(frame);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
} catch { /* ignore */ }
|
|
371
|
+
debug("e2ee", `Frame decryption failed (tried both binary and base64)`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Dispatch a fully parsed frame (post-decryption). */
|
|
376
|
+
private dispatchFrame(frame: AnyClusterFrame) {
|
|
220
377
|
if (!this.authenticated) {
|
|
221
|
-
|
|
378
|
+
this.handleAuthMessage(frame);
|
|
222
379
|
return;
|
|
223
380
|
}
|
|
224
381
|
|
|
225
|
-
// Approval pending: HMAC passed but auth_ok not yet sent.
|
|
226
|
-
// Only allow ping/pong — drop all business frames.
|
|
227
382
|
if (!this.authOkSent) {
|
|
228
|
-
if (frame
|
|
383
|
+
if (frame.type !== "ping" && frame.type !== "pong") return;
|
|
229
384
|
}
|
|
230
385
|
|
|
231
|
-
if (frame
|
|
232
|
-
this.send({
|
|
233
|
-
type: "pong",
|
|
234
|
-
from: this.nodeId,
|
|
235
|
-
timestamp: Date.now(),
|
|
236
|
-
} as AnyClusterFrame);
|
|
386
|
+
if (frame.type === "ping") {
|
|
387
|
+
this.send({ type: "pong", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
|
|
237
388
|
return;
|
|
238
389
|
}
|
|
239
|
-
if (frame
|
|
390
|
+
if (frame.type === "pong") {
|
|
240
391
|
this.missedPongs = 0;
|
|
241
392
|
if (this.lastPingSentAt > 0) {
|
|
242
393
|
const rtt = Date.now() - this.lastPingSentAt;
|
|
@@ -246,7 +397,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
246
397
|
return;
|
|
247
398
|
}
|
|
248
399
|
|
|
249
|
-
this.emit("message", frame
|
|
400
|
+
this.emit("message", frame);
|
|
250
401
|
}
|
|
251
402
|
|
|
252
403
|
private async handleAuthMessage(frame: any) {
|
|
@@ -296,7 +447,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
296
447
|
// Signal the outbound that auth is pending so it extends its timer
|
|
297
448
|
// instead of timing out after AUTH_TIMEOUT (10s).
|
|
298
449
|
if (this.sessionKey) {
|
|
299
|
-
|
|
450
|
+
// Send immediately (bypass batch) so client gets it as a standalone frame
|
|
451
|
+
const pending = { type: "auth_pending", from: this.nodeId, timestamp: Date.now() };
|
|
452
|
+
this.sendRaw(encryptBinaryRaw(this.sessionKey, JSON.stringify(pending), false));
|
|
300
453
|
} else {
|
|
301
454
|
this.sendRaw({ p: 1 });
|
|
302
455
|
}
|
|
@@ -453,7 +606,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
453
606
|
} as AuthOk;
|
|
454
607
|
|
|
455
608
|
if (this.sessionKey) {
|
|
456
|
-
|
|
609
|
+
// Send auth_ok immediately (bypass batch queue) so the client
|
|
610
|
+
// receives it as a standalone binary frame, not mixed into a batch.
|
|
611
|
+
this.sendRaw(encryptBinaryRaw(this.sessionKey, JSON.stringify(authOk), false));
|
|
457
612
|
} else {
|
|
458
613
|
this.sendRaw(authOk);
|
|
459
614
|
}
|
|
@@ -488,8 +643,19 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
488
643
|
this.scheduleHeartbeatTick();
|
|
489
644
|
}
|
|
490
645
|
|
|
646
|
+
/** Compute adaptive heartbeat interval based on measured latency. */
|
|
647
|
+
private adaptiveHeartbeatInterval(): number {
|
|
648
|
+
if (this.latencyMs > 0) {
|
|
649
|
+
// Low-latency (50ms) → 6s, high-latency (3s) → 20s
|
|
650
|
+
const base = Math.max(6_000, Math.min(this.latencyMs * 8, 20_000));
|
|
651
|
+
return base + Math.random() * 3_000;
|
|
652
|
+
}
|
|
653
|
+
// No latency measured yet — use default
|
|
654
|
+
return HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
|
|
655
|
+
}
|
|
656
|
+
|
|
491
657
|
private scheduleHeartbeatTick() {
|
|
492
|
-
const interval =
|
|
658
|
+
const interval = this.adaptiveHeartbeatInterval();
|
|
493
659
|
this.heartbeatTimer = setTimeout(this.heartbeatTick, interval);
|
|
494
660
|
}
|
|
495
661
|
|
|
@@ -505,6 +671,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
505
671
|
return;
|
|
506
672
|
}
|
|
507
673
|
|
|
674
|
+
// Skip ping if recent traffic proves the connection is alive
|
|
675
|
+
const currentInterval = this.adaptiveHeartbeatInterval();
|
|
676
|
+
const timeSinceReceive = Date.now() - this.lastReceivedAt;
|
|
677
|
+
if (this.lastReceivedAt > 0 && timeSinceReceive < currentInterval * 0.5) {
|
|
678
|
+
this.missedPongs = 0;
|
|
679
|
+
this.scheduleHeartbeatTick();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
508
683
|
// Increment before checking: this ping is about to be sent and
|
|
509
684
|
// counts as outstanding until a pong arrives.
|
|
510
685
|
this.missedPongs++;
|
|
@@ -551,13 +726,16 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
551
726
|
|
|
552
727
|
private dummyTick = () => {
|
|
553
728
|
if (this.closed || !this.sessionKey) return;
|
|
554
|
-
|
|
729
|
+
// Dummy traffic bypasses batch queue — sent directly
|
|
730
|
+
this.sendDummy();
|
|
555
731
|
this.scheduleDummyTick();
|
|
556
732
|
};
|
|
557
733
|
|
|
558
734
|
// ── Cleanup ────────────────────────────────────────────────────
|
|
559
735
|
close(code = 1000, reason = "normal") {
|
|
560
736
|
if (this.closed) return;
|
|
737
|
+
// Flush any pending batch before closing
|
|
738
|
+
this.flushBatch();
|
|
561
739
|
this.closed = true;
|
|
562
740
|
this.clearAuthTimer();
|
|
563
741
|
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");
|