clawmatrix 0.2.9 → 0.3.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/LICENSE +27 -0
- package/README.md +123 -12
- package/package.json +2 -1
- package/src/acp-proxy.ts +433 -70
- package/src/cli.ts +478 -10
- package/src/cluster-service.ts +158 -14
- package/src/compat.ts +0 -6
- package/src/config.ts +17 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +721 -0
- package/src/handoff.ts +21 -8
- package/src/health-tracker.ts +6 -1
- package/src/index.ts +245 -11
- package/src/knowledge-sync.ts +74 -7
- package/src/model-proxy.ts +35 -10
- package/src/peer-manager.ts +84 -13
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +59 -7
- package/src/sentinel.ts +13 -3
- package/src/terminal.ts +2 -1
- package/src/tool-proxy.ts +12 -4
- package/src/tools/cluster-diagnostic.ts +5 -2
- package/src/tools/cluster-edit.ts +2 -1
- package/src/tools/cluster-events.ts +3 -1
- package/src/tools/cluster-exec.ts +2 -0
- package/src/tools/cluster-handoff.ts +3 -1
- package/src/tools/cluster-peers.ts +3 -1
- package/src/tools/cluster-read.ts +4 -1
- package/src/tools/cluster-send.ts +2 -1
- package/src/tools/cluster-terminal.ts +4 -7
- package/src/tools/cluster-tool.ts +2 -2
- package/src/tools/cluster-transfer.ts +91 -0
- package/src/tools/cluster-write.ts +3 -1
- package/src/types.ts +191 -2
- package/src/web.ts +2 -10
- package/src/web-ui.ts +0 -1622
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, stat, mkdir, lstat, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { debug } from "./debug.ts";
|
|
5
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
6
|
+
import type { ClawMatrixConfig } from "./config.ts";
|
|
7
|
+
import type {
|
|
8
|
+
FileTransferInit,
|
|
9
|
+
FileTransferAck,
|
|
10
|
+
FileTransferChunk,
|
|
11
|
+
FileTransferChunkAck,
|
|
12
|
+
FileTransferComplete,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
interface FileTransferConfig {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
chunkSize: number;
|
|
18
|
+
maxFileSize: number;
|
|
19
|
+
timeout: number;
|
|
20
|
+
allowedPaths: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PendingTransfer {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
direction: "push" | "pull";
|
|
26
|
+
filePath: string;
|
|
27
|
+
targetPath: string;
|
|
28
|
+
remoteNode: string;
|
|
29
|
+
resolve: (result: TransferResult) => void;
|
|
30
|
+
reject: (err: Error) => void;
|
|
31
|
+
timer: ReturnType<typeof setTimeout>;
|
|
32
|
+
// Push state
|
|
33
|
+
fileData?: Buffer;
|
|
34
|
+
chunkSize?: number;
|
|
35
|
+
totalChunks?: number;
|
|
36
|
+
sentChunks?: number;
|
|
37
|
+
// Pull state
|
|
38
|
+
chunks?: Map<number, Buffer>;
|
|
39
|
+
expectedSize?: number;
|
|
40
|
+
expectedChunks?: number;
|
|
41
|
+
expectedChecksum?: string;
|
|
42
|
+
receivedChunks?: number;
|
|
43
|
+
onProgress?: (progress: TransferProgress) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ReceivingTransfer {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
direction: "push" | "pull";
|
|
49
|
+
filePath: string;
|
|
50
|
+
targetPath: string;
|
|
51
|
+
fromNode: string;
|
|
52
|
+
fileSize: number;
|
|
53
|
+
totalChunks: number;
|
|
54
|
+
chunkSize: number;
|
|
55
|
+
checksum: string;
|
|
56
|
+
chunks: Map<number, Buffer>;
|
|
57
|
+
receivedChunks: number;
|
|
58
|
+
timer: ReturnType<typeof setTimeout>;
|
|
59
|
+
/** Cached file data for pull-mode serving to avoid re-reading per chunk */
|
|
60
|
+
cachedData?: Buffer;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TransferProgress {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
direction: "push" | "pull";
|
|
66
|
+
sentChunks: number;
|
|
67
|
+
totalChunks: number;
|
|
68
|
+
bytesTransferred: number;
|
|
69
|
+
totalBytes: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TransferOptions {
|
|
73
|
+
/** Called after each chunk is acknowledged. */
|
|
74
|
+
onProgress?: (progress: TransferProgress) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TransferResult {
|
|
78
|
+
success: boolean;
|
|
79
|
+
bytesTransferred: number;
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class FileTransferManager {
|
|
84
|
+
private config: FileTransferConfig;
|
|
85
|
+
private nodeId: string;
|
|
86
|
+
private peerManager: PeerManager;
|
|
87
|
+
private pending = new Map<string, PendingTransfer>();
|
|
88
|
+
private receiving = new Map<string, ReceivingTransfer>();
|
|
89
|
+
|
|
90
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
91
|
+
this.nodeId = config.nodeId;
|
|
92
|
+
this.peerManager = peerManager;
|
|
93
|
+
const ft = config.fileTransfer;
|
|
94
|
+
this.config = {
|
|
95
|
+
enabled: ft?.enabled ?? false,
|
|
96
|
+
chunkSize: ft?.chunkSize ?? 262_144,
|
|
97
|
+
maxFileSize: ft?.maxFileSize ?? 104_857_600,
|
|
98
|
+
timeout: ft?.timeout ?? 300_000,
|
|
99
|
+
allowedPaths: ft?.allowedPaths ?? [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
async pushFile(remoteNode: string, localPath: string, remotePath: string, opts?: TransferOptions): Promise<TransferResult> {
|
|
106
|
+
this.ensureEnabled();
|
|
107
|
+
const resolvedPath = path.resolve(localPath);
|
|
108
|
+
await this.validatePath(resolvedPath);
|
|
109
|
+
|
|
110
|
+
const fileData = await readFile(resolvedPath);
|
|
111
|
+
if (fileData.length > this.config.maxFileSize) {
|
|
112
|
+
throw new Error(`File too large: ${fileData.length} bytes (max ${this.config.maxFileSize})`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const checksum = createHash("sha256").update(fileData).digest("hex");
|
|
116
|
+
const totalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
|
|
117
|
+
const sessionId = crypto.randomUUID();
|
|
118
|
+
|
|
119
|
+
return new Promise<TransferResult>((resolve, reject) => {
|
|
120
|
+
const timer = this.createTimer(sessionId, "pending");
|
|
121
|
+
|
|
122
|
+
this.pending.set(sessionId, {
|
|
123
|
+
sessionId,
|
|
124
|
+
direction: "push",
|
|
125
|
+
filePath: resolvedPath,
|
|
126
|
+
targetPath: remotePath,
|
|
127
|
+
remoteNode,
|
|
128
|
+
resolve,
|
|
129
|
+
reject,
|
|
130
|
+
timer,
|
|
131
|
+
fileData,
|
|
132
|
+
chunkSize: this.config.chunkSize,
|
|
133
|
+
totalChunks,
|
|
134
|
+
sentChunks: 0,
|
|
135
|
+
onProgress: opts?.onProgress,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.peerManager.sendTo(remoteNode, {
|
|
139
|
+
type: "file_transfer_init",
|
|
140
|
+
id: sessionId,
|
|
141
|
+
from: this.nodeId,
|
|
142
|
+
to: remoteNode,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
payload: {
|
|
145
|
+
sessionId,
|
|
146
|
+
direction: "push",
|
|
147
|
+
filePath: resolvedPath,
|
|
148
|
+
targetPath: remotePath,
|
|
149
|
+
fileSize: fileData.length,
|
|
150
|
+
totalChunks,
|
|
151
|
+
chunkSize: this.config.chunkSize,
|
|
152
|
+
checksum,
|
|
153
|
+
},
|
|
154
|
+
} as FileTransferInit);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async pullFile(remoteNode: string, remotePath: string, localPath: string, opts?: TransferOptions): Promise<TransferResult> {
|
|
159
|
+
this.ensureEnabled();
|
|
160
|
+
const resolvedPath = path.resolve(localPath);
|
|
161
|
+
await this.validatePath(resolvedPath);
|
|
162
|
+
|
|
163
|
+
const sessionId = crypto.randomUUID();
|
|
164
|
+
|
|
165
|
+
return new Promise<TransferResult>((resolve, reject) => {
|
|
166
|
+
const timer = this.createTimer(sessionId, "pending");
|
|
167
|
+
|
|
168
|
+
this.pending.set(sessionId, {
|
|
169
|
+
sessionId,
|
|
170
|
+
direction: "pull",
|
|
171
|
+
filePath: remotePath,
|
|
172
|
+
targetPath: resolvedPath,
|
|
173
|
+
remoteNode,
|
|
174
|
+
resolve,
|
|
175
|
+
reject,
|
|
176
|
+
timer,
|
|
177
|
+
chunks: new Map(),
|
|
178
|
+
receivedChunks: 0,
|
|
179
|
+
onProgress: opts?.onProgress,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
this.peerManager.sendTo(remoteNode, {
|
|
183
|
+
type: "file_transfer_init",
|
|
184
|
+
id: sessionId,
|
|
185
|
+
from: this.nodeId,
|
|
186
|
+
to: remoteNode,
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
payload: {
|
|
189
|
+
sessionId,
|
|
190
|
+
direction: "pull",
|
|
191
|
+
filePath: remotePath,
|
|
192
|
+
targetPath: resolvedPath,
|
|
193
|
+
fileSize: 0,
|
|
194
|
+
totalChunks: 0,
|
|
195
|
+
chunkSize: this.config.chunkSize,
|
|
196
|
+
checksum: "",
|
|
197
|
+
},
|
|
198
|
+
} as FileTransferInit);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Frame handlers (called by cluster-service dispatch) ─────────
|
|
203
|
+
|
|
204
|
+
async handleInit(frame: FileTransferInit): Promise<void> {
|
|
205
|
+
const { sessionId, direction, filePath, targetPath, fileSize, totalChunks, chunkSize, checksum } = frame.payload;
|
|
206
|
+
|
|
207
|
+
// Validate enabled
|
|
208
|
+
if (!this.config.enabled) {
|
|
209
|
+
this.sendAck(frame.from, sessionId, frame.id, false, "File transfer not enabled on this node");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (direction === "push") {
|
|
214
|
+
// Remote wants to push a file to us
|
|
215
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
216
|
+
if (!(await this.isPathAllowed(resolvedTarget))) {
|
|
217
|
+
this.sendAck(frame.from, sessionId, frame.id, false, "Target path not allowed");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (fileSize > this.config.maxFileSize) {
|
|
221
|
+
this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileSize} bytes (max ${this.config.maxFileSize})`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Accept and prepare to receive chunks
|
|
226
|
+
const timer = this.createTimer(sessionId, "receiving");
|
|
227
|
+
this.receiving.set(sessionId, {
|
|
228
|
+
sessionId,
|
|
229
|
+
direction: "push",
|
|
230
|
+
filePath,
|
|
231
|
+
targetPath: resolvedTarget,
|
|
232
|
+
fromNode: frame.from,
|
|
233
|
+
fileSize,
|
|
234
|
+
totalChunks,
|
|
235
|
+
chunkSize,
|
|
236
|
+
checksum,
|
|
237
|
+
chunks: new Map(),
|
|
238
|
+
receivedChunks: 0,
|
|
239
|
+
timer,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
this.sendAck(frame.from, sessionId, frame.id, true);
|
|
243
|
+
} else {
|
|
244
|
+
// Remote wants to pull a file from us
|
|
245
|
+
const resolvedSource = path.resolve(filePath);
|
|
246
|
+
if (!(await this.isPathAllowed(resolvedSource))) {
|
|
247
|
+
this.sendAck(frame.from, sessionId, frame.id, false, "Source path not allowed");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let fileData: Buffer;
|
|
252
|
+
try {
|
|
253
|
+
fileData = await readFile(resolvedSource);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.sendAck(frame.from, sessionId, frame.id, false, `Cannot read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (fileData.length > this.config.maxFileSize) {
|
|
260
|
+
this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileData.length} bytes`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const fileChecksum = createHash("sha256").update(fileData).digest("hex");
|
|
265
|
+
const fileTotalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
|
|
266
|
+
|
|
267
|
+
// Send ack with file metadata
|
|
268
|
+
this.peerManager.sendTo(frame.from, {
|
|
269
|
+
type: "file_transfer_ack",
|
|
270
|
+
id: frame.id,
|
|
271
|
+
from: this.nodeId,
|
|
272
|
+
to: frame.from,
|
|
273
|
+
timestamp: Date.now(),
|
|
274
|
+
payload: {
|
|
275
|
+
sessionId,
|
|
276
|
+
accepted: true,
|
|
277
|
+
fileSize: fileData.length,
|
|
278
|
+
totalChunks: fileTotalChunks,
|
|
279
|
+
checksum: fileChecksum,
|
|
280
|
+
},
|
|
281
|
+
} as FileTransferAck);
|
|
282
|
+
|
|
283
|
+
// Store as a "receiving" entry for tracking (we are the sender in pull mode)
|
|
284
|
+
const timer = this.createTimer(sessionId, "receiving");
|
|
285
|
+
this.receiving.set(sessionId, {
|
|
286
|
+
sessionId,
|
|
287
|
+
direction: "pull",
|
|
288
|
+
filePath: resolvedSource,
|
|
289
|
+
targetPath,
|
|
290
|
+
fromNode: frame.from,
|
|
291
|
+
fileSize: fileData.length,
|
|
292
|
+
totalChunks: fileTotalChunks,
|
|
293
|
+
chunkSize: this.config.chunkSize,
|
|
294
|
+
checksum: fileChecksum,
|
|
295
|
+
chunks: new Map(),
|
|
296
|
+
receivedChunks: 0,
|
|
297
|
+
timer,
|
|
298
|
+
cachedData: fileData,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Start sending chunks (stop-and-wait: send first, wait for ack)
|
|
302
|
+
this.sendChunkFromReceiving(sessionId, 0, fileData);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
handleAck(frame: FileTransferAck): void {
|
|
307
|
+
const { sessionId, accepted, error, fileSize, totalChunks, checksum } = frame.payload;
|
|
308
|
+
const transfer = this.pending.get(sessionId);
|
|
309
|
+
if (!transfer) return;
|
|
310
|
+
|
|
311
|
+
this.resetTimer(sessionId, "pending");
|
|
312
|
+
|
|
313
|
+
if (!accepted) {
|
|
314
|
+
this.completePending(sessionId, { success: false, bytesTransferred: 0, error: error || "Transfer rejected" });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (transfer.direction === "push") {
|
|
319
|
+
// Start sending chunks
|
|
320
|
+
this.sendNextChunk(sessionId);
|
|
321
|
+
} else {
|
|
322
|
+
// Pull mode: store expected metadata
|
|
323
|
+
transfer.expectedSize = fileSize;
|
|
324
|
+
transfer.expectedChunks = totalChunks;
|
|
325
|
+
transfer.expectedChecksum = checksum;
|
|
326
|
+
// Wait for chunks from remote
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
handleChunk(frame: FileTransferChunk): void {
|
|
331
|
+
const { sessionId, chunkIndex, data } = frame.payload;
|
|
332
|
+
|
|
333
|
+
// Check if this is a pull we initiated (we're receiving)
|
|
334
|
+
const pending = this.pending.get(sessionId);
|
|
335
|
+
if (pending && pending.direction === "pull") {
|
|
336
|
+
this.resetTimer(sessionId, "pending");
|
|
337
|
+
const buf = Buffer.from(data, "base64");
|
|
338
|
+
pending.chunks!.set(chunkIndex, buf);
|
|
339
|
+
pending.receivedChunks = (pending.receivedChunks ?? 0) + 1;
|
|
340
|
+
|
|
341
|
+
// Send chunk ack
|
|
342
|
+
this.peerManager.sendTo(pending.remoteNode, {
|
|
343
|
+
type: "file_transfer_chunk_ack",
|
|
344
|
+
id: frame.id,
|
|
345
|
+
from: this.nodeId,
|
|
346
|
+
to: pending.remoteNode,
|
|
347
|
+
timestamp: Date.now(),
|
|
348
|
+
payload: { sessionId, chunkIndex, success: true },
|
|
349
|
+
} as FileTransferChunkAck);
|
|
350
|
+
|
|
351
|
+
pending.onProgress?.({
|
|
352
|
+
sessionId,
|
|
353
|
+
direction: "pull",
|
|
354
|
+
sentChunks: pending.receivedChunks!,
|
|
355
|
+
totalChunks: pending.expectedChunks ?? 0,
|
|
356
|
+
// Use current chunk size as proxy — Math.min clamps the last (smaller) chunk correctly
|
|
357
|
+
bytesTransferred: Math.min(pending.receivedChunks! * buf.length, pending.expectedSize ?? 0),
|
|
358
|
+
totalBytes: pending.expectedSize ?? 0,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Check if all chunks received
|
|
362
|
+
if (pending.receivedChunks === pending.expectedChunks) {
|
|
363
|
+
this.finalizePull(sessionId).catch((err) => {
|
|
364
|
+
this.completePending(sessionId, {
|
|
365
|
+
success: false,
|
|
366
|
+
bytesTransferred: 0,
|
|
367
|
+
error: err instanceof Error ? err.message : String(err),
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Otherwise, this is a push from remote (we're receiving)
|
|
375
|
+
const receiving = this.receiving.get(sessionId);
|
|
376
|
+
if (!receiving || receiving.direction !== "push") return;
|
|
377
|
+
|
|
378
|
+
this.resetTimer(sessionId, "receiving");
|
|
379
|
+
const buf = Buffer.from(data, "base64");
|
|
380
|
+
receiving.chunks.set(chunkIndex, buf);
|
|
381
|
+
receiving.receivedChunks++;
|
|
382
|
+
|
|
383
|
+
// Send chunk ack
|
|
384
|
+
this.peerManager.sendTo(receiving.fromNode, {
|
|
385
|
+
type: "file_transfer_chunk_ack",
|
|
386
|
+
id: frame.id,
|
|
387
|
+
from: this.nodeId,
|
|
388
|
+
to: receiving.fromNode,
|
|
389
|
+
timestamp: Date.now(),
|
|
390
|
+
payload: { sessionId, chunkIndex, success: true },
|
|
391
|
+
} as FileTransferChunkAck);
|
|
392
|
+
|
|
393
|
+
// Check if all chunks received
|
|
394
|
+
if (receiving.receivedChunks === receiving.totalChunks) {
|
|
395
|
+
this.finalizePushReceive(sessionId).catch((err) => {
|
|
396
|
+
debug("file-transfer", `finalizePushReceive error: ${err}`);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
handleChunkAck(frame: FileTransferChunkAck): void {
|
|
402
|
+
const { sessionId, chunkIndex, success, error } = frame.payload;
|
|
403
|
+
|
|
404
|
+
// Push mode: we sent a chunk, got ack, send next
|
|
405
|
+
const pending = this.pending.get(sessionId);
|
|
406
|
+
if (pending && pending.direction === "push") {
|
|
407
|
+
this.resetTimer(sessionId, "pending");
|
|
408
|
+
if (!success) {
|
|
409
|
+
this.completePending(sessionId, {
|
|
410
|
+
success: false,
|
|
411
|
+
bytesTransferred: chunkIndex * (pending.chunkSize ?? this.config.chunkSize),
|
|
412
|
+
error: error || `Chunk ${chunkIndex} rejected`,
|
|
413
|
+
});
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
pending.sentChunks = chunkIndex + 1;
|
|
417
|
+
pending.onProgress?.({
|
|
418
|
+
sessionId,
|
|
419
|
+
direction: "push",
|
|
420
|
+
sentChunks: pending.sentChunks!,
|
|
421
|
+
totalChunks: pending.totalChunks!,
|
|
422
|
+
bytesTransferred: Math.min(pending.sentChunks! * (pending.chunkSize ?? this.config.chunkSize), pending.fileData?.length ?? 0),
|
|
423
|
+
totalBytes: pending.fileData?.length ?? 0,
|
|
424
|
+
});
|
|
425
|
+
if (pending.sentChunks! < pending.totalChunks!) {
|
|
426
|
+
this.sendNextChunk(sessionId);
|
|
427
|
+
}
|
|
428
|
+
// If all sent, wait for file_transfer_complete from receiver
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Pull mode (we are serving): got chunk ack, send next
|
|
433
|
+
const receiving = this.receiving.get(sessionId);
|
|
434
|
+
if (receiving && receiving.direction === "pull") {
|
|
435
|
+
this.resetTimer(sessionId, "receiving");
|
|
436
|
+
if (!success) {
|
|
437
|
+
this.cleanupReceiving(sessionId);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const nextIndex = chunkIndex + 1;
|
|
441
|
+
if (nextIndex < receiving.totalChunks) {
|
|
442
|
+
if (receiving.cachedData) {
|
|
443
|
+
this.sendChunkFromReceiving(sessionId, nextIndex, receiving.cachedData);
|
|
444
|
+
} else {
|
|
445
|
+
// Fallback: read file if cached data is missing
|
|
446
|
+
readFile(receiving.filePath).then((data) => {
|
|
447
|
+
receiving.cachedData = data;
|
|
448
|
+
this.sendChunkFromReceiving(sessionId, nextIndex, data);
|
|
449
|
+
}).catch(() => {
|
|
450
|
+
this.cleanupReceiving(sessionId);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// If all chunks sent, wait for file_transfer_complete from puller
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
handleComplete(frame: FileTransferComplete): void {
|
|
459
|
+
const { sessionId, success, error, bytesTransferred } = frame.payload;
|
|
460
|
+
|
|
461
|
+
// Pending transfer completed
|
|
462
|
+
const pending = this.pending.get(sessionId);
|
|
463
|
+
if (pending) {
|
|
464
|
+
this.completePending(sessionId, {
|
|
465
|
+
success,
|
|
466
|
+
bytesTransferred: bytesTransferred ?? 0,
|
|
467
|
+
error: error || undefined,
|
|
468
|
+
});
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Receiving transfer completed (pull mode serving — remote confirms)
|
|
473
|
+
const receiving = this.receiving.get(sessionId);
|
|
474
|
+
if (receiving) {
|
|
475
|
+
this.cleanupReceiving(sessionId);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
destroy(): void {
|
|
480
|
+
for (const [, transfer] of this.pending) {
|
|
481
|
+
clearTimeout(transfer.timer);
|
|
482
|
+
transfer.reject(new Error("FileTransferManager destroyed"));
|
|
483
|
+
}
|
|
484
|
+
this.pending.clear();
|
|
485
|
+
|
|
486
|
+
for (const [, transfer] of this.receiving) {
|
|
487
|
+
clearTimeout(transfer.timer);
|
|
488
|
+
}
|
|
489
|
+
this.receiving.clear();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Internal helpers ────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
private ensureEnabled(): void {
|
|
495
|
+
if (!this.config.enabled) {
|
|
496
|
+
throw new Error("File transfer is not enabled");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private async validatePath(resolvedPath: string): Promise<void> {
|
|
501
|
+
if (!(await this.isPathAllowed(resolvedPath))) {
|
|
502
|
+
throw new Error(`Path not allowed: ${resolvedPath}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async isPathAllowed(resolvedPath: string): Promise<boolean> {
|
|
507
|
+
// TODO(security): allowedPaths 为空时默认允许所有路径,当前仅用于受信任网络。
|
|
508
|
+
// 开放到非受信环境前需改为默认拒绝,或要求显式配置 allowedPaths。
|
|
509
|
+
if (this.config.allowedPaths.length === 0) return true;
|
|
510
|
+
|
|
511
|
+
// Resolve symlinks to prevent path traversal via symlink
|
|
512
|
+
let realResolved: string;
|
|
513
|
+
try {
|
|
514
|
+
realResolved = await realpath(resolvedPath);
|
|
515
|
+
} catch {
|
|
516
|
+
// File doesn't exist yet (for write targets) — check parent directory
|
|
517
|
+
const parentDir = path.dirname(resolvedPath);
|
|
518
|
+
try {
|
|
519
|
+
realResolved = path.join(await realpath(parentDir), path.basename(resolvedPath));
|
|
520
|
+
} catch {
|
|
521
|
+
// Parent doesn't exist either — use the resolved path as-is
|
|
522
|
+
realResolved = resolvedPath;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return this.config.allowedPaths.some((allowed) => {
|
|
527
|
+
const resolvedAllowed = path.resolve(allowed);
|
|
528
|
+
return realResolved === resolvedAllowed || realResolved.startsWith(resolvedAllowed + path.sep);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private sendAck(to: string, sessionId: string, frameId: string, accepted: boolean, error?: string): void {
|
|
533
|
+
this.peerManager.sendTo(to, {
|
|
534
|
+
type: "file_transfer_ack",
|
|
535
|
+
id: frameId,
|
|
536
|
+
from: this.nodeId,
|
|
537
|
+
to,
|
|
538
|
+
timestamp: Date.now(),
|
|
539
|
+
payload: { sessionId, accepted, error },
|
|
540
|
+
} as FileTransferAck);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private sendNextChunk(sessionId: string): void {
|
|
544
|
+
const transfer = this.pending.get(sessionId);
|
|
545
|
+
if (!transfer || !transfer.fileData) return;
|
|
546
|
+
|
|
547
|
+
const chunkIndex = transfer.sentChunks!;
|
|
548
|
+
const start = chunkIndex * transfer.chunkSize!;
|
|
549
|
+
const end = Math.min(start + transfer.chunkSize!, transfer.fileData.length);
|
|
550
|
+
const chunk = transfer.fileData.subarray(start, end);
|
|
551
|
+
|
|
552
|
+
this.peerManager.sendTo(transfer.remoteNode, {
|
|
553
|
+
type: "file_transfer_chunk",
|
|
554
|
+
id: transfer.sessionId,
|
|
555
|
+
from: this.nodeId,
|
|
556
|
+
to: transfer.remoteNode,
|
|
557
|
+
timestamp: Date.now(),
|
|
558
|
+
payload: {
|
|
559
|
+
sessionId,
|
|
560
|
+
chunkIndex,
|
|
561
|
+
data: chunk.toString("base64"),
|
|
562
|
+
},
|
|
563
|
+
} as FileTransferChunk);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private sendChunkFromReceiving(sessionId: string, chunkIndex: number, fileData: Buffer): void {
|
|
567
|
+
const receiving = this.receiving.get(sessionId);
|
|
568
|
+
if (!receiving) return;
|
|
569
|
+
|
|
570
|
+
const start = chunkIndex * receiving.chunkSize;
|
|
571
|
+
const end = Math.min(start + receiving.chunkSize, fileData.length);
|
|
572
|
+
const chunk = fileData.subarray(start, end);
|
|
573
|
+
|
|
574
|
+
this.peerManager.sendTo(receiving.fromNode, {
|
|
575
|
+
type: "file_transfer_chunk",
|
|
576
|
+
id: sessionId,
|
|
577
|
+
from: this.nodeId,
|
|
578
|
+
to: receiving.fromNode,
|
|
579
|
+
timestamp: Date.now(),
|
|
580
|
+
payload: {
|
|
581
|
+
sessionId,
|
|
582
|
+
chunkIndex,
|
|
583
|
+
data: chunk.toString("base64"),
|
|
584
|
+
},
|
|
585
|
+
} as FileTransferChunk);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async finalizePushReceive(sessionId: string): Promise<void> {
|
|
589
|
+
const receiving = this.receiving.get(sessionId);
|
|
590
|
+
if (!receiving) return;
|
|
591
|
+
|
|
592
|
+
// Reassemble file
|
|
593
|
+
const buffers: Buffer[] = [];
|
|
594
|
+
for (let i = 0; i < receiving.totalChunks; i++) {
|
|
595
|
+
const chunk = receiving.chunks.get(i);
|
|
596
|
+
if (!chunk) {
|
|
597
|
+
this.sendComplete(receiving.fromNode, sessionId, false, "Missing chunk " + i);
|
|
598
|
+
this.cleanupReceiving(sessionId);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
buffers.push(chunk);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const assembled = Buffer.concat(buffers);
|
|
605
|
+
|
|
606
|
+
// Verify checksum
|
|
607
|
+
const actualChecksum = createHash("sha256").update(assembled).digest("hex");
|
|
608
|
+
if (actualChecksum !== receiving.checksum) {
|
|
609
|
+
this.sendComplete(receiving.fromNode, sessionId, false, `Checksum mismatch: expected ${receiving.checksum}, got ${actualChecksum}`);
|
|
610
|
+
this.cleanupReceiving(sessionId);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Write file
|
|
615
|
+
try {
|
|
616
|
+
await mkdir(path.dirname(receiving.targetPath), { recursive: true });
|
|
617
|
+
await writeFile(receiving.targetPath, assembled);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
this.sendComplete(receiving.fromNode, sessionId, false, `Write error: ${err instanceof Error ? err.message : String(err)}`);
|
|
620
|
+
this.cleanupReceiving(sessionId);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
this.sendComplete(receiving.fromNode, sessionId, true, undefined, assembled.length);
|
|
625
|
+
this.cleanupReceiving(sessionId);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private async finalizePull(sessionId: string): Promise<void> {
|
|
629
|
+
const transfer = this.pending.get(sessionId);
|
|
630
|
+
if (!transfer) return;
|
|
631
|
+
|
|
632
|
+
// Reassemble file
|
|
633
|
+
const buffers: Buffer[] = [];
|
|
634
|
+
for (let i = 0; i < transfer.expectedChunks!; i++) {
|
|
635
|
+
const chunk = transfer.chunks!.get(i);
|
|
636
|
+
if (!chunk) {
|
|
637
|
+
throw new Error(`Missing chunk ${i}`);
|
|
638
|
+
}
|
|
639
|
+
buffers.push(chunk);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const assembled = Buffer.concat(buffers);
|
|
643
|
+
|
|
644
|
+
// Verify checksum
|
|
645
|
+
const actualChecksum = createHash("sha256").update(assembled).digest("hex");
|
|
646
|
+
if (actualChecksum !== transfer.expectedChecksum) {
|
|
647
|
+
// Notify remote of failure
|
|
648
|
+
this.sendComplete(transfer.remoteNode, sessionId, false, `Checksum mismatch: expected ${transfer.expectedChecksum}, got ${actualChecksum}`);
|
|
649
|
+
this.completePending(sessionId, {
|
|
650
|
+
success: false,
|
|
651
|
+
bytesTransferred: 0,
|
|
652
|
+
error: `Checksum mismatch`,
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Write file
|
|
658
|
+
await mkdir(path.dirname(transfer.targetPath), { recursive: true });
|
|
659
|
+
await writeFile(transfer.targetPath, assembled);
|
|
660
|
+
|
|
661
|
+
// Notify remote of success
|
|
662
|
+
this.sendComplete(transfer.remoteNode, sessionId, true, undefined, assembled.length);
|
|
663
|
+
this.completePending(sessionId, { success: true, bytesTransferred: assembled.length });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private sendComplete(to: string, sessionId: string, success: boolean, error?: string, bytesTransferred?: number): void {
|
|
667
|
+
this.peerManager.sendTo(to, {
|
|
668
|
+
type: "file_transfer_complete",
|
|
669
|
+
id: sessionId,
|
|
670
|
+
from: this.nodeId,
|
|
671
|
+
to,
|
|
672
|
+
timestamp: Date.now(),
|
|
673
|
+
payload: { sessionId, success, error, bytesTransferred },
|
|
674
|
+
} as FileTransferComplete);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private completePending(sessionId: string, result: TransferResult): void {
|
|
678
|
+
const transfer = this.pending.get(sessionId);
|
|
679
|
+
if (!transfer) return;
|
|
680
|
+
clearTimeout(transfer.timer);
|
|
681
|
+
this.pending.delete(sessionId);
|
|
682
|
+
transfer.resolve(result);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private cleanupReceiving(sessionId: string): void {
|
|
686
|
+
const receiving = this.receiving.get(sessionId);
|
|
687
|
+
if (!receiving) return;
|
|
688
|
+
clearTimeout(receiving.timer);
|
|
689
|
+
this.receiving.delete(sessionId);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private createTimer(sessionId: string, mapType: "pending" | "receiving"): ReturnType<typeof setTimeout> {
|
|
693
|
+
return setTimeout(() => {
|
|
694
|
+
if (mapType === "pending") {
|
|
695
|
+
const transfer = this.pending.get(sessionId);
|
|
696
|
+
if (transfer) {
|
|
697
|
+
this.pending.delete(sessionId);
|
|
698
|
+
transfer.reject(new Error("File transfer timeout"));
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
this.receiving.delete(sessionId);
|
|
702
|
+
}
|
|
703
|
+
}, this.config.timeout);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private resetTimer(sessionId: string, mapType: "pending" | "receiving"): void {
|
|
707
|
+
if (mapType === "pending") {
|
|
708
|
+
const transfer = this.pending.get(sessionId);
|
|
709
|
+
if (transfer) {
|
|
710
|
+
clearTimeout(transfer.timer);
|
|
711
|
+
transfer.timer = this.createTimer(sessionId, "pending");
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
const receiving = this.receiving.get(sessionId);
|
|
715
|
+
if (receiving) {
|
|
716
|
+
clearTimeout(receiving.timer);
|
|
717
|
+
receiving.timer = this.createTimer(sessionId, "receiving");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|