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/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,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
- // 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));
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 raw data. Strings sent as-is (for binary envelopes); objects JSON-encoded. */
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
+ }
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 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}`);
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
- try {
214
- frame = JSON.parse(str);
215
- } catch {
216
- return;
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
- await this.handleAuthMessage(frame!);
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!.type !== "ping" && frame!.type !== "pong") return;
393
+ if (frame.type !== "ping" && frame.type !== "pong") return;
229
394
  }
230
395
 
231
- if (frame!.type === "ping") {
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!.type === "pong") {
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
- this.send({ type: "auth_pending", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
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
- this.send(authOk);
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 = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
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
- this.send({ type: "_d", from: "", timestamp: 0 } as unknown as AnyClusterFrame);
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 { 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");