clawmatrix 0.4.2 → 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/src/connection.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EventEmitter } from "node:events";
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
- // Full frame encryption → binary base64 envelope (no JSON structure on wire)
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 raw data. Strings sent as-is (for binary envelopes); objects JSON-encoded. */
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
- this.transport.send(typeof data === "string" ? data : JSON.stringify(data));
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 encrypted envelope (base64, not JSON)
200
- if (this.sessionKey && isBinaryEncrypted(str)) {
201
- try {
202
- frame = JSON.parse(decryptBinary(this.sessionKey, str));
203
- } catch (err) {
204
- debug("e2ee", `Frame decryption failed: ${err}`);
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
- try {
214
- frame = JSON.parse(str);
215
- } catch {
216
- return;
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
- await this.handleAuthMessage(frame!);
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!.type !== "ping" && frame!.type !== "pong") return;
383
+ if (frame.type !== "ping" && frame.type !== "pong") return;
229
384
  }
230
385
 
231
- if (frame!.type === "ping") {
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!.type === "pong") {
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
- this.send({ type: "auth_pending", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
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
- this.send(authOk);
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 = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
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
- this.send({ type: "_d", from: "", timestamp: 0 } as unknown as AnyClusterFrame);
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 { deflateSync, inflateSync } from "node:zlib";
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 = false,
96
+ compress: boolean = true,
97
97
  ): string {
98
98
  let data: Buffer;
99
99
  let flags = 0;
100
100
 
101
- if (compress && plaintext.length > 256) {
102
- const deflated = deflateSync(Buffer.from(plaintext));
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 inflateSync(data).toString();
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. */
@@ -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
  }
@@ -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 = crypto.randomUUID();
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 = crypto.randomUUID();
164
+ const sessionId = nanoid();
164
165
 
165
166
  return new Promise<TransferResult>((resolve, reject) => {
166
167
  const timer = this.createTimer(sessionId, "pending");