clawmatrix 0.2.9 → 0.2.11
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/package.json +1 -1
- package/src/acp-proxy.ts +30 -6
- package/src/cluster-service.ts +44 -0
- package/src/config.ts +9 -0
- package/src/file-transfer.ts +671 -0
- package/src/health-tracker.ts +6 -1
- package/src/index.ts +11 -6
- package/src/knowledge-sync.ts +30 -1
- package/src/peer-manager.ts +3 -0
- package/src/sentinel-manager.ts +8 -7
- package/src/terminal.ts +2 -1
- package/src/tools/cluster-diagnostic.ts +2 -0
- package/src/tools/cluster-transfer.ts +91 -0
- package/src/types.ts +88 -1
package/package.json
CHANGED
package/src/acp-proxy.ts
CHANGED
|
@@ -153,6 +153,8 @@ export class AcpProxy {
|
|
|
153
153
|
private warmPool = new Map<string, AcpSession[]>();
|
|
154
154
|
// Track pending prewarm timers so they can be cancelled on dispose
|
|
155
155
|
private prewarmTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
156
|
+
// Track pending retry timers for sendResponse so they can be cancelled on destroy
|
|
157
|
+
private retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
156
158
|
private disposed = false;
|
|
157
159
|
// Agent daemon pool: long-lived process per agent type, reused across sessions
|
|
158
160
|
private daemons = new Map<string, AgentDaemon>();
|
|
@@ -1246,6 +1248,7 @@ export class AcpProxy {
|
|
|
1246
1248
|
},
|
|
1247
1249
|
availableModes,
|
|
1248
1250
|
currentModeId: response.modes?.currentModeId,
|
|
1251
|
+
|
|
1249
1252
|
};
|
|
1250
1253
|
|
|
1251
1254
|
return session;
|
|
@@ -1524,6 +1527,7 @@ export class AcpProxy {
|
|
|
1524
1527
|
setStreamCallback: (cb) => { streamCallback = cb; },
|
|
1525
1528
|
availableModes,
|
|
1526
1529
|
currentModeId: response.modes?.currentModeId,
|
|
1530
|
+
|
|
1527
1531
|
};
|
|
1528
1532
|
|
|
1529
1533
|
this.monitorProcess(session);
|
|
@@ -1777,9 +1781,21 @@ export class AcpProxy {
|
|
|
1777
1781
|
cwd: string,
|
|
1778
1782
|
from: string,
|
|
1779
1783
|
): Promise<AcpSession> {
|
|
1780
|
-
return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", (conn) =>
|
|
1781
|
-
|
|
1782
|
-
|
|
1784
|
+
return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", async (conn, effectiveCwd) => {
|
|
1785
|
+
// Try session/load first (supported by Codex, Claude Code, and most ACP agents).
|
|
1786
|
+
// It restores conversation history and replays it via notifications.
|
|
1787
|
+
try {
|
|
1788
|
+
const loadResp = await conn.loadSession({ sessionId: acpSessionId, cwd: effectiveCwd, mcpServers: [] });
|
|
1789
|
+
debug("acp", `loadSession succeeded for ${agent} (acpSessionId=${acpSessionId.slice(0, 8)}...)`);
|
|
1790
|
+
return { sessionId: acpSessionId, modes: loadResp.modes };
|
|
1791
|
+
} catch (loadErr) {
|
|
1792
|
+
debug("acp", `loadSession failed for ${agent}: ${errorMessage(loadErr)}, trying session/resume`);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Fallback to session/resume (unstable, supported by Claude Code).
|
|
1796
|
+
// Resumes without replaying history — faster but less widely supported.
|
|
1797
|
+
return conn.unstable_resumeSession({ sessionId: acpSessionId, cwd: effectiveCwd });
|
|
1798
|
+
});
|
|
1783
1799
|
}
|
|
1784
1800
|
|
|
1785
1801
|
/** Read all session stores from disk (OpenClaw + Claude Code). */
|
|
@@ -2020,11 +2036,14 @@ export class AcpProxy {
|
|
|
2020
2036
|
const retryDelays = [2_000, 5_000, 10_000];
|
|
2021
2037
|
let attempt = 0;
|
|
2022
2038
|
const retry = () => {
|
|
2023
|
-
if (attempt >= retryDelays.length) {
|
|
2024
|
-
|
|
2039
|
+
if (this.disposed || attempt >= retryDelays.length) {
|
|
2040
|
+
if (!this.disposed) {
|
|
2041
|
+
console.error(`[clawmatrix:acp] Failed to deliver acp_res to ${to} after ${retryDelays.length} retries`);
|
|
2042
|
+
}
|
|
2025
2043
|
return;
|
|
2026
2044
|
}
|
|
2027
|
-
setTimeout(() => {
|
|
2045
|
+
const timer = setTimeout(() => {
|
|
2046
|
+
this.retryTimers.delete(timer);
|
|
2028
2047
|
frame.timestamp = Date.now();
|
|
2029
2048
|
if (this.peerManager.sendTo(to, frame)) {
|
|
2030
2049
|
debug("acp", `acp_res to ${to} delivered on retry ${attempt + 1}`);
|
|
@@ -2033,6 +2052,7 @@ export class AcpProxy {
|
|
|
2033
2052
|
retry();
|
|
2034
2053
|
}
|
|
2035
2054
|
}, retryDelays[attempt]);
|
|
2055
|
+
this.retryTimers.add(timer);
|
|
2036
2056
|
};
|
|
2037
2057
|
retry();
|
|
2038
2058
|
}
|
|
@@ -2073,6 +2093,10 @@ export class AcpProxy {
|
|
|
2073
2093
|
|
|
2074
2094
|
this.disposed = true;
|
|
2075
2095
|
|
|
2096
|
+
// Cancel pending retry timers
|
|
2097
|
+
for (const timer of this.retryTimers) clearTimeout(timer);
|
|
2098
|
+
this.retryTimers.clear();
|
|
2099
|
+
|
|
2076
2100
|
// Cancel pending prewarm timers
|
|
2077
2101
|
for (const timer of this.prewarmTimers) clearTimeout(timer);
|
|
2078
2102
|
this.prewarmTimers.clear();
|
package/src/cluster-service.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { ModelProxy } from "./model-proxy.ts";
|
|
|
14
14
|
import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
|
|
15
15
|
import { AcpProxy, readAllSessionStoresFromDisk } from "./acp-proxy.ts";
|
|
16
16
|
import { TerminalManager } from "./terminal.ts";
|
|
17
|
+
import { FileTransferManager } from "./file-transfer.ts";
|
|
17
18
|
import { WebHandler } from "./web.ts";
|
|
18
19
|
import { KnowledgeSync } from "./knowledge-sync.ts";
|
|
19
20
|
import { HealthTracker } from "./health-tracker.ts";
|
|
@@ -30,6 +31,8 @@ import type {
|
|
|
30
31
|
HandoffInput,
|
|
31
32
|
KnowledgeSyncFrame,
|
|
32
33
|
HealthSyncFrame,
|
|
34
|
+
AvailabilityRequest,
|
|
35
|
+
AvailabilityResponse,
|
|
33
36
|
ModelRequest,
|
|
34
37
|
ModelResponse,
|
|
35
38
|
ModelStreamChunk,
|
|
@@ -63,6 +66,11 @@ import type {
|
|
|
63
66
|
TerminalResize,
|
|
64
67
|
TerminalCloseRequest,
|
|
65
68
|
TerminalCloseResponse,
|
|
69
|
+
FileTransferInit,
|
|
70
|
+
FileTransferAck,
|
|
71
|
+
FileTransferChunk,
|
|
72
|
+
FileTransferChunkAck,
|
|
73
|
+
FileTransferComplete,
|
|
66
74
|
} from "./types.ts";
|
|
67
75
|
|
|
68
76
|
function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
|
|
@@ -86,6 +94,7 @@ export class ClusterRuntime {
|
|
|
86
94
|
readonly toolProxy: ToolProxy;
|
|
87
95
|
readonly acpProxy: AcpProxy | null;
|
|
88
96
|
readonly terminalManager: TerminalManager;
|
|
97
|
+
readonly fileTransferManager: FileTransferManager | null;
|
|
89
98
|
knowledgeSync: KnowledgeSync | null = null;
|
|
90
99
|
healthTracker: HealthTracker;
|
|
91
100
|
webHandler: WebHandler | null = null;
|
|
@@ -107,6 +116,9 @@ export class ClusterRuntime {
|
|
|
107
116
|
const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
|
|
108
117
|
this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
|
|
109
118
|
this.terminalManager = new TerminalManager(config, this.peerManager);
|
|
119
|
+
this.fileTransferManager = config.fileTransfer?.enabled
|
|
120
|
+
? new FileTransferManager(config, this.peerManager)
|
|
121
|
+
: null;
|
|
110
122
|
this.healthTracker = new HealthTracker({
|
|
111
123
|
nodeId: config.nodeId,
|
|
112
124
|
peerManager: this.peerManager,
|
|
@@ -238,6 +250,7 @@ export class ClusterRuntime {
|
|
|
238
250
|
this.acpProxy?.destroy();
|
|
239
251
|
this.terminalManager.destroy();
|
|
240
252
|
this.modelProxy.stop();
|
|
253
|
+
this.fileTransferManager?.destroy();
|
|
241
254
|
this.toolProxy.destroy();
|
|
242
255
|
await this.peerManager.stop();
|
|
243
256
|
this.logger.info(`[clawmatrix] Node "${this.config.nodeId}" stopped`);
|
|
@@ -338,6 +351,20 @@ export class ClusterRuntime {
|
|
|
338
351
|
case "health_sync":
|
|
339
352
|
this.healthTracker.handleSyncMessage(frame as HealthSyncFrame);
|
|
340
353
|
break;
|
|
354
|
+
case "availability_req": {
|
|
355
|
+
const af = frame as AvailabilityRequest;
|
|
356
|
+
const range = af.payload.range ?? "24h";
|
|
357
|
+
const data = this.healthTracker.getAvailability(range);
|
|
358
|
+
this.peerManager.sendTo(af.from, {
|
|
359
|
+
type: "availability_res",
|
|
360
|
+
id: af.id,
|
|
361
|
+
from: this.config.nodeId,
|
|
362
|
+
to: af.from,
|
|
363
|
+
timestamp: Date.now(),
|
|
364
|
+
payload: { success: true, data },
|
|
365
|
+
} as AvailabilityResponse);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
341
368
|
case "acp_req":
|
|
342
369
|
if (this.acpProxy) {
|
|
343
370
|
this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
|
|
@@ -494,6 +521,23 @@ export class ClusterRuntime {
|
|
|
494
521
|
frame as TerminalOpenRequest | TerminalOpenResponse | TerminalData | TerminalResize | TerminalCloseRequest | TerminalCloseResponse,
|
|
495
522
|
);
|
|
496
523
|
break;
|
|
524
|
+
case "file_transfer_init":
|
|
525
|
+
this.fileTransferManager?.handleInit(frame as FileTransferInit).catch((err) => {
|
|
526
|
+
this.logger.error(`[clawmatrix] File transfer init error: ${err}`);
|
|
527
|
+
});
|
|
528
|
+
break;
|
|
529
|
+
case "file_transfer_ack":
|
|
530
|
+
this.fileTransferManager?.handleAck(frame as FileTransferAck);
|
|
531
|
+
break;
|
|
532
|
+
case "file_transfer_chunk":
|
|
533
|
+
this.fileTransferManager?.handleChunk(frame as FileTransferChunk);
|
|
534
|
+
break;
|
|
535
|
+
case "file_transfer_chunk_ack":
|
|
536
|
+
this.fileTransferManager?.handleChunkAck(frame as FileTransferChunkAck);
|
|
537
|
+
break;
|
|
538
|
+
case "file_transfer_complete":
|
|
539
|
+
this.fileTransferManager?.handleComplete(frame as FileTransferComplete);
|
|
540
|
+
break;
|
|
497
541
|
}
|
|
498
542
|
}
|
|
499
543
|
|
package/src/config.ts
CHANGED
|
@@ -134,6 +134,14 @@ const TerminalConfigSchema = z.object({
|
|
|
134
134
|
allowFrom: z.array(z.string()).default([]),
|
|
135
135
|
}).optional();
|
|
136
136
|
|
|
137
|
+
const FileTransferConfigSchema = z.object({
|
|
138
|
+
enabled: z.boolean().default(false),
|
|
139
|
+
chunkSize: z.number().default(262_144), // 256KB
|
|
140
|
+
maxFileSize: z.number().default(104_857_600), // 100MB
|
|
141
|
+
timeout: z.number().default(300_000), // 5min per-chunk
|
|
142
|
+
allowedPaths: z.array(z.string()).default([]), // empty = no restriction
|
|
143
|
+
}).optional();
|
|
144
|
+
|
|
137
145
|
const AcpConfigSchema = z.object({
|
|
138
146
|
enabled: z.boolean().default(false),
|
|
139
147
|
/** ACP agents available on this node. Advertised to peers via capabilities. */
|
|
@@ -169,6 +177,7 @@ const RawClawMatrixConfigSchema = z.object({
|
|
|
169
177
|
knowledge: KnowledgeConfigSchema,
|
|
170
178
|
terminal: TerminalConfigSchema,
|
|
171
179
|
acp: AcpConfigSchema,
|
|
180
|
+
fileTransfer: FileTransferConfigSchema,
|
|
172
181
|
peerApproval: z.union([
|
|
173
182
|
z.boolean(), // true = required mode, false = disabled
|
|
174
183
|
PeerApprovalConfigSchema,
|
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, stat, mkdir } 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
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ReceivingTransfer {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
direction: "push" | "pull";
|
|
48
|
+
filePath: string;
|
|
49
|
+
targetPath: string;
|
|
50
|
+
fromNode: string;
|
|
51
|
+
fileSize: number;
|
|
52
|
+
totalChunks: number;
|
|
53
|
+
chunkSize: number;
|
|
54
|
+
checksum: string;
|
|
55
|
+
chunks: Map<number, Buffer>;
|
|
56
|
+
receivedChunks: number;
|
|
57
|
+
timer: ReturnType<typeof setTimeout>;
|
|
58
|
+
/** Cached file data for pull-mode serving to avoid re-reading per chunk */
|
|
59
|
+
cachedData?: Buffer;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TransferResult {
|
|
63
|
+
success: boolean;
|
|
64
|
+
bytesTransferred: number;
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class FileTransferManager {
|
|
69
|
+
private config: FileTransferConfig;
|
|
70
|
+
private nodeId: string;
|
|
71
|
+
private peerManager: PeerManager;
|
|
72
|
+
private pending = new Map<string, PendingTransfer>();
|
|
73
|
+
private receiving = new Map<string, ReceivingTransfer>();
|
|
74
|
+
|
|
75
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
76
|
+
this.nodeId = config.nodeId;
|
|
77
|
+
this.peerManager = peerManager;
|
|
78
|
+
const ft = config.fileTransfer;
|
|
79
|
+
this.config = {
|
|
80
|
+
enabled: ft?.enabled ?? false,
|
|
81
|
+
chunkSize: ft?.chunkSize ?? 262_144,
|
|
82
|
+
maxFileSize: ft?.maxFileSize ?? 104_857_600,
|
|
83
|
+
timeout: ft?.timeout ?? 300_000,
|
|
84
|
+
allowedPaths: ft?.allowedPaths ?? [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
async pushFile(remoteNode: string, localPath: string, remotePath: string): Promise<TransferResult> {
|
|
91
|
+
this.ensureEnabled();
|
|
92
|
+
const resolvedPath = path.resolve(localPath);
|
|
93
|
+
this.validatePath(resolvedPath);
|
|
94
|
+
|
|
95
|
+
const fileData = await readFile(resolvedPath);
|
|
96
|
+
if (fileData.length > this.config.maxFileSize) {
|
|
97
|
+
throw new Error(`File too large: ${fileData.length} bytes (max ${this.config.maxFileSize})`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const checksum = createHash("sha256").update(fileData).digest("hex");
|
|
101
|
+
const totalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
|
|
102
|
+
const sessionId = crypto.randomUUID();
|
|
103
|
+
|
|
104
|
+
return new Promise<TransferResult>((resolve, reject) => {
|
|
105
|
+
const timer = this.createTimer(sessionId, "pending");
|
|
106
|
+
|
|
107
|
+
this.pending.set(sessionId, {
|
|
108
|
+
sessionId,
|
|
109
|
+
direction: "push",
|
|
110
|
+
filePath: resolvedPath,
|
|
111
|
+
targetPath: remotePath,
|
|
112
|
+
remoteNode,
|
|
113
|
+
resolve,
|
|
114
|
+
reject,
|
|
115
|
+
timer,
|
|
116
|
+
fileData,
|
|
117
|
+
chunkSize: this.config.chunkSize,
|
|
118
|
+
totalChunks,
|
|
119
|
+
sentChunks: 0,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.peerManager.sendTo(remoteNode, {
|
|
123
|
+
type: "file_transfer_init",
|
|
124
|
+
id: sessionId,
|
|
125
|
+
from: this.nodeId,
|
|
126
|
+
to: remoteNode,
|
|
127
|
+
timestamp: Date.now(),
|
|
128
|
+
payload: {
|
|
129
|
+
sessionId,
|
|
130
|
+
direction: "push",
|
|
131
|
+
filePath: resolvedPath,
|
|
132
|
+
targetPath: remotePath,
|
|
133
|
+
fileSize: fileData.length,
|
|
134
|
+
totalChunks,
|
|
135
|
+
chunkSize: this.config.chunkSize,
|
|
136
|
+
checksum,
|
|
137
|
+
},
|
|
138
|
+
} as FileTransferInit);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async pullFile(remoteNode: string, remotePath: string, localPath: string): Promise<TransferResult> {
|
|
143
|
+
this.ensureEnabled();
|
|
144
|
+
const resolvedPath = path.resolve(localPath);
|
|
145
|
+
this.validatePath(resolvedPath);
|
|
146
|
+
|
|
147
|
+
const sessionId = crypto.randomUUID();
|
|
148
|
+
|
|
149
|
+
return new Promise<TransferResult>((resolve, reject) => {
|
|
150
|
+
const timer = this.createTimer(sessionId, "pending");
|
|
151
|
+
|
|
152
|
+
this.pending.set(sessionId, {
|
|
153
|
+
sessionId,
|
|
154
|
+
direction: "pull",
|
|
155
|
+
filePath: remotePath,
|
|
156
|
+
targetPath: resolvedPath,
|
|
157
|
+
remoteNode,
|
|
158
|
+
resolve,
|
|
159
|
+
reject,
|
|
160
|
+
timer,
|
|
161
|
+
chunks: new Map(),
|
|
162
|
+
receivedChunks: 0,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.peerManager.sendTo(remoteNode, {
|
|
166
|
+
type: "file_transfer_init",
|
|
167
|
+
id: sessionId,
|
|
168
|
+
from: this.nodeId,
|
|
169
|
+
to: remoteNode,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
payload: {
|
|
172
|
+
sessionId,
|
|
173
|
+
direction: "pull",
|
|
174
|
+
filePath: remotePath,
|
|
175
|
+
targetPath: resolvedPath,
|
|
176
|
+
fileSize: 0,
|
|
177
|
+
totalChunks: 0,
|
|
178
|
+
chunkSize: this.config.chunkSize,
|
|
179
|
+
checksum: "",
|
|
180
|
+
},
|
|
181
|
+
} as FileTransferInit);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Frame handlers (called by cluster-service dispatch) ─────────
|
|
186
|
+
|
|
187
|
+
async handleInit(frame: FileTransferInit): Promise<void> {
|
|
188
|
+
const { sessionId, direction, filePath, targetPath, fileSize, totalChunks, chunkSize, checksum } = frame.payload;
|
|
189
|
+
|
|
190
|
+
// Validate enabled
|
|
191
|
+
if (!this.config.enabled) {
|
|
192
|
+
this.sendAck(frame.from, sessionId, frame.id, false, "File transfer not enabled on this node");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (direction === "push") {
|
|
197
|
+
// Remote wants to push a file to us
|
|
198
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
199
|
+
if (!this.isPathAllowed(resolvedTarget)) {
|
|
200
|
+
this.sendAck(frame.from, sessionId, frame.id, false, "Target path not allowed");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (fileSize > this.config.maxFileSize) {
|
|
204
|
+
this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileSize} bytes (max ${this.config.maxFileSize})`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Accept and prepare to receive chunks
|
|
209
|
+
const timer = this.createTimer(sessionId, "receiving");
|
|
210
|
+
this.receiving.set(sessionId, {
|
|
211
|
+
sessionId,
|
|
212
|
+
direction: "push",
|
|
213
|
+
filePath,
|
|
214
|
+
targetPath: resolvedTarget,
|
|
215
|
+
fromNode: frame.from,
|
|
216
|
+
fileSize,
|
|
217
|
+
totalChunks,
|
|
218
|
+
chunkSize,
|
|
219
|
+
checksum,
|
|
220
|
+
chunks: new Map(),
|
|
221
|
+
receivedChunks: 0,
|
|
222
|
+
timer,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
this.sendAck(frame.from, sessionId, frame.id, true);
|
|
226
|
+
} else {
|
|
227
|
+
// Remote wants to pull a file from us
|
|
228
|
+
const resolvedSource = path.resolve(filePath);
|
|
229
|
+
if (!this.isPathAllowed(resolvedSource)) {
|
|
230
|
+
this.sendAck(frame.from, sessionId, frame.id, false, "Source path not allowed");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let fileData: Buffer;
|
|
235
|
+
try {
|
|
236
|
+
fileData = await readFile(resolvedSource);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
this.sendAck(frame.from, sessionId, frame.id, false, `Cannot read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (fileData.length > this.config.maxFileSize) {
|
|
243
|
+
this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileData.length} bytes`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const fileChecksum = createHash("sha256").update(fileData).digest("hex");
|
|
248
|
+
const fileTotalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
|
|
249
|
+
|
|
250
|
+
// Send ack with file metadata
|
|
251
|
+
this.peerManager.sendTo(frame.from, {
|
|
252
|
+
type: "file_transfer_ack",
|
|
253
|
+
id: frame.id,
|
|
254
|
+
from: this.nodeId,
|
|
255
|
+
to: frame.from,
|
|
256
|
+
timestamp: Date.now(),
|
|
257
|
+
payload: {
|
|
258
|
+
sessionId,
|
|
259
|
+
accepted: true,
|
|
260
|
+
fileSize: fileData.length,
|
|
261
|
+
totalChunks: fileTotalChunks,
|
|
262
|
+
checksum: fileChecksum,
|
|
263
|
+
},
|
|
264
|
+
} as FileTransferAck);
|
|
265
|
+
|
|
266
|
+
// Store as a "receiving" entry for tracking (we are the sender in pull mode)
|
|
267
|
+
const timer = this.createTimer(sessionId, "receiving");
|
|
268
|
+
this.receiving.set(sessionId, {
|
|
269
|
+
sessionId,
|
|
270
|
+
direction: "pull",
|
|
271
|
+
filePath: resolvedSource,
|
|
272
|
+
targetPath,
|
|
273
|
+
fromNode: frame.from,
|
|
274
|
+
fileSize: fileData.length,
|
|
275
|
+
totalChunks: fileTotalChunks,
|
|
276
|
+
chunkSize: this.config.chunkSize,
|
|
277
|
+
checksum: fileChecksum,
|
|
278
|
+
chunks: new Map(),
|
|
279
|
+
receivedChunks: 0,
|
|
280
|
+
timer,
|
|
281
|
+
cachedData: fileData,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Start sending chunks (stop-and-wait: send first, wait for ack)
|
|
285
|
+
this.sendChunkFromReceiving(sessionId, 0, fileData);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
handleAck(frame: FileTransferAck): void {
|
|
290
|
+
const { sessionId, accepted, error, fileSize, totalChunks, checksum } = frame.payload;
|
|
291
|
+
const transfer = this.pending.get(sessionId);
|
|
292
|
+
if (!transfer) return;
|
|
293
|
+
|
|
294
|
+
this.resetTimer(sessionId, "pending");
|
|
295
|
+
|
|
296
|
+
if (!accepted) {
|
|
297
|
+
this.completePending(sessionId, { success: false, bytesTransferred: 0, error: error || "Transfer rejected" });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (transfer.direction === "push") {
|
|
302
|
+
// Start sending chunks
|
|
303
|
+
this.sendNextChunk(sessionId);
|
|
304
|
+
} else {
|
|
305
|
+
// Pull mode: store expected metadata
|
|
306
|
+
transfer.expectedSize = fileSize;
|
|
307
|
+
transfer.expectedChunks = totalChunks;
|
|
308
|
+
transfer.expectedChecksum = checksum;
|
|
309
|
+
// Wait for chunks from remote
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
handleChunk(frame: FileTransferChunk): void {
|
|
314
|
+
const { sessionId, chunkIndex, data } = frame.payload;
|
|
315
|
+
|
|
316
|
+
// Check if this is a pull we initiated (we're receiving)
|
|
317
|
+
const pending = this.pending.get(sessionId);
|
|
318
|
+
if (pending && pending.direction === "pull") {
|
|
319
|
+
this.resetTimer(sessionId, "pending");
|
|
320
|
+
const buf = Buffer.from(data, "base64");
|
|
321
|
+
pending.chunks!.set(chunkIndex, buf);
|
|
322
|
+
pending.receivedChunks = (pending.receivedChunks ?? 0) + 1;
|
|
323
|
+
|
|
324
|
+
// Send chunk ack
|
|
325
|
+
this.peerManager.sendTo(pending.remoteNode, {
|
|
326
|
+
type: "file_transfer_chunk_ack",
|
|
327
|
+
id: frame.id,
|
|
328
|
+
from: this.nodeId,
|
|
329
|
+
to: pending.remoteNode,
|
|
330
|
+
timestamp: Date.now(),
|
|
331
|
+
payload: { sessionId, chunkIndex, success: true },
|
|
332
|
+
} as FileTransferChunkAck);
|
|
333
|
+
|
|
334
|
+
// Check if all chunks received
|
|
335
|
+
if (pending.receivedChunks === pending.expectedChunks) {
|
|
336
|
+
this.finalizePull(sessionId).catch((err) => {
|
|
337
|
+
this.completePending(sessionId, {
|
|
338
|
+
success: false,
|
|
339
|
+
bytesTransferred: 0,
|
|
340
|
+
error: err instanceof Error ? err.message : String(err),
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Otherwise, this is a push from remote (we're receiving)
|
|
348
|
+
const receiving = this.receiving.get(sessionId);
|
|
349
|
+
if (!receiving || receiving.direction !== "push") return;
|
|
350
|
+
|
|
351
|
+
this.resetTimer(sessionId, "receiving");
|
|
352
|
+
const buf = Buffer.from(data, "base64");
|
|
353
|
+
receiving.chunks.set(chunkIndex, buf);
|
|
354
|
+
receiving.receivedChunks++;
|
|
355
|
+
|
|
356
|
+
// Send chunk ack
|
|
357
|
+
this.peerManager.sendTo(receiving.fromNode, {
|
|
358
|
+
type: "file_transfer_chunk_ack",
|
|
359
|
+
id: frame.id,
|
|
360
|
+
from: this.nodeId,
|
|
361
|
+
to: receiving.fromNode,
|
|
362
|
+
timestamp: Date.now(),
|
|
363
|
+
payload: { sessionId, chunkIndex, success: true },
|
|
364
|
+
} as FileTransferChunkAck);
|
|
365
|
+
|
|
366
|
+
// Check if all chunks received
|
|
367
|
+
if (receiving.receivedChunks === receiving.totalChunks) {
|
|
368
|
+
this.finalizePushReceive(sessionId).catch((err) => {
|
|
369
|
+
debug("file-transfer", `finalizePushReceive error: ${err}`);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
handleChunkAck(frame: FileTransferChunkAck): void {
|
|
375
|
+
const { sessionId, chunkIndex, success, error } = frame.payload;
|
|
376
|
+
|
|
377
|
+
// Push mode: we sent a chunk, got ack, send next
|
|
378
|
+
const pending = this.pending.get(sessionId);
|
|
379
|
+
if (pending && pending.direction === "push") {
|
|
380
|
+
this.resetTimer(sessionId, "pending");
|
|
381
|
+
if (!success) {
|
|
382
|
+
this.completePending(sessionId, {
|
|
383
|
+
success: false,
|
|
384
|
+
bytesTransferred: chunkIndex * (pending.chunkSize ?? this.config.chunkSize),
|
|
385
|
+
error: error || `Chunk ${chunkIndex} rejected`,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
pending.sentChunks = chunkIndex + 1;
|
|
390
|
+
if (pending.sentChunks! < pending.totalChunks!) {
|
|
391
|
+
this.sendNextChunk(sessionId);
|
|
392
|
+
}
|
|
393
|
+
// If all sent, wait for file_transfer_complete from receiver
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Pull mode (we are serving): got chunk ack, send next
|
|
398
|
+
const receiving = this.receiving.get(sessionId);
|
|
399
|
+
if (receiving && receiving.direction === "pull") {
|
|
400
|
+
this.resetTimer(sessionId, "receiving");
|
|
401
|
+
if (!success) {
|
|
402
|
+
this.cleanupReceiving(sessionId);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const nextIndex = chunkIndex + 1;
|
|
406
|
+
if (nextIndex < receiving.totalChunks) {
|
|
407
|
+
if (receiving.cachedData) {
|
|
408
|
+
this.sendChunkFromReceiving(sessionId, nextIndex, receiving.cachedData);
|
|
409
|
+
} else {
|
|
410
|
+
// Fallback: read file if cached data is missing
|
|
411
|
+
readFile(receiving.filePath).then((data) => {
|
|
412
|
+
receiving.cachedData = data;
|
|
413
|
+
this.sendChunkFromReceiving(sessionId, nextIndex, data);
|
|
414
|
+
}).catch(() => {
|
|
415
|
+
this.cleanupReceiving(sessionId);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// If all chunks sent, wait for file_transfer_complete from puller
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
handleComplete(frame: FileTransferComplete): void {
|
|
424
|
+
const { sessionId, success, error, bytesTransferred } = frame.payload;
|
|
425
|
+
|
|
426
|
+
// Pending transfer completed
|
|
427
|
+
const pending = this.pending.get(sessionId);
|
|
428
|
+
if (pending) {
|
|
429
|
+
this.completePending(sessionId, {
|
|
430
|
+
success,
|
|
431
|
+
bytesTransferred: bytesTransferred ?? 0,
|
|
432
|
+
error: error || undefined,
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Receiving transfer completed (pull mode serving — remote confirms)
|
|
438
|
+
const receiving = this.receiving.get(sessionId);
|
|
439
|
+
if (receiving) {
|
|
440
|
+
this.cleanupReceiving(sessionId);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
destroy(): void {
|
|
445
|
+
for (const [id, transfer] of this.pending) {
|
|
446
|
+
clearTimeout(transfer.timer);
|
|
447
|
+
transfer.reject(new Error("FileTransferManager destroyed"));
|
|
448
|
+
}
|
|
449
|
+
this.pending.clear();
|
|
450
|
+
|
|
451
|
+
for (const [id, transfer] of this.receiving) {
|
|
452
|
+
clearTimeout(transfer.timer);
|
|
453
|
+
}
|
|
454
|
+
this.receiving.clear();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Internal helpers ────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
private ensureEnabled(): void {
|
|
460
|
+
if (!this.config.enabled) {
|
|
461
|
+
throw new Error("File transfer is not enabled");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private validatePath(resolvedPath: string): void {
|
|
466
|
+
if (!this.isPathAllowed(resolvedPath)) {
|
|
467
|
+
throw new Error(`Path not allowed: ${resolvedPath}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private isPathAllowed(resolvedPath: string): boolean {
|
|
472
|
+
// TODO(security): allowedPaths 为空时默认允许所有路径,当前仅用于受信任网络。
|
|
473
|
+
// 开放到非受信环境前需改为默认拒绝,或要求显式配置 allowedPaths。
|
|
474
|
+
if (this.config.allowedPaths.length === 0) return true;
|
|
475
|
+
const normalized = path.resolve(resolvedPath);
|
|
476
|
+
return this.config.allowedPaths.some((allowed) => {
|
|
477
|
+
const resolvedAllowed = path.resolve(allowed);
|
|
478
|
+
return normalized === resolvedAllowed || normalized.startsWith(resolvedAllowed + path.sep);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private sendAck(to: string, sessionId: string, frameId: string, accepted: boolean, error?: string): void {
|
|
483
|
+
this.peerManager.sendTo(to, {
|
|
484
|
+
type: "file_transfer_ack",
|
|
485
|
+
id: frameId,
|
|
486
|
+
from: this.nodeId,
|
|
487
|
+
to,
|
|
488
|
+
timestamp: Date.now(),
|
|
489
|
+
payload: { sessionId, accepted, error },
|
|
490
|
+
} as FileTransferAck);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private sendNextChunk(sessionId: string): void {
|
|
494
|
+
const transfer = this.pending.get(sessionId);
|
|
495
|
+
if (!transfer || !transfer.fileData) return;
|
|
496
|
+
|
|
497
|
+
const chunkIndex = transfer.sentChunks!;
|
|
498
|
+
const start = chunkIndex * transfer.chunkSize!;
|
|
499
|
+
const end = Math.min(start + transfer.chunkSize!, transfer.fileData.length);
|
|
500
|
+
const chunk = transfer.fileData.subarray(start, end);
|
|
501
|
+
|
|
502
|
+
this.peerManager.sendTo(transfer.remoteNode, {
|
|
503
|
+
type: "file_transfer_chunk",
|
|
504
|
+
id: transfer.sessionId,
|
|
505
|
+
from: this.nodeId,
|
|
506
|
+
to: transfer.remoteNode,
|
|
507
|
+
timestamp: Date.now(),
|
|
508
|
+
payload: {
|
|
509
|
+
sessionId,
|
|
510
|
+
chunkIndex,
|
|
511
|
+
data: chunk.toString("base64"),
|
|
512
|
+
},
|
|
513
|
+
} as FileTransferChunk);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private sendChunkFromReceiving(sessionId: string, chunkIndex: number, fileData: Buffer): void {
|
|
517
|
+
const receiving = this.receiving.get(sessionId);
|
|
518
|
+
if (!receiving) return;
|
|
519
|
+
|
|
520
|
+
const start = chunkIndex * receiving.chunkSize;
|
|
521
|
+
const end = Math.min(start + receiving.chunkSize, fileData.length);
|
|
522
|
+
const chunk = fileData.subarray(start, end);
|
|
523
|
+
|
|
524
|
+
this.peerManager.sendTo(receiving.fromNode, {
|
|
525
|
+
type: "file_transfer_chunk",
|
|
526
|
+
id: sessionId,
|
|
527
|
+
from: this.nodeId,
|
|
528
|
+
to: receiving.fromNode,
|
|
529
|
+
timestamp: Date.now(),
|
|
530
|
+
payload: {
|
|
531
|
+
sessionId,
|
|
532
|
+
chunkIndex,
|
|
533
|
+
data: chunk.toString("base64"),
|
|
534
|
+
},
|
|
535
|
+
} as FileTransferChunk);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async finalizePushReceive(sessionId: string): Promise<void> {
|
|
539
|
+
const receiving = this.receiving.get(sessionId);
|
|
540
|
+
if (!receiving) return;
|
|
541
|
+
|
|
542
|
+
// Reassemble file
|
|
543
|
+
const buffers: Buffer[] = [];
|
|
544
|
+
for (let i = 0; i < receiving.totalChunks; i++) {
|
|
545
|
+
const chunk = receiving.chunks.get(i);
|
|
546
|
+
if (!chunk) {
|
|
547
|
+
this.sendComplete(receiving.fromNode, sessionId, false, "Missing chunk " + i);
|
|
548
|
+
this.cleanupReceiving(sessionId);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
buffers.push(chunk);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const assembled = Buffer.concat(buffers);
|
|
555
|
+
|
|
556
|
+
// Verify checksum
|
|
557
|
+
const actualChecksum = createHash("sha256").update(assembled).digest("hex");
|
|
558
|
+
if (actualChecksum !== receiving.checksum) {
|
|
559
|
+
this.sendComplete(receiving.fromNode, sessionId, false, `Checksum mismatch: expected ${receiving.checksum}, got ${actualChecksum}`);
|
|
560
|
+
this.cleanupReceiving(sessionId);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Write file
|
|
565
|
+
try {
|
|
566
|
+
await mkdir(path.dirname(receiving.targetPath), { recursive: true });
|
|
567
|
+
await writeFile(receiving.targetPath, assembled);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
this.sendComplete(receiving.fromNode, sessionId, false, `Write error: ${err instanceof Error ? err.message : String(err)}`);
|
|
570
|
+
this.cleanupReceiving(sessionId);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.sendComplete(receiving.fromNode, sessionId, true, undefined, assembled.length);
|
|
575
|
+
this.cleanupReceiving(sessionId);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async finalizePull(sessionId: string): Promise<void> {
|
|
579
|
+
const transfer = this.pending.get(sessionId);
|
|
580
|
+
if (!transfer) return;
|
|
581
|
+
|
|
582
|
+
// Reassemble file
|
|
583
|
+
const buffers: Buffer[] = [];
|
|
584
|
+
for (let i = 0; i < transfer.expectedChunks!; i++) {
|
|
585
|
+
const chunk = transfer.chunks!.get(i);
|
|
586
|
+
if (!chunk) {
|
|
587
|
+
throw new Error(`Missing chunk ${i}`);
|
|
588
|
+
}
|
|
589
|
+
buffers.push(chunk);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const assembled = Buffer.concat(buffers);
|
|
593
|
+
|
|
594
|
+
// Verify checksum
|
|
595
|
+
const actualChecksum = createHash("sha256").update(assembled).digest("hex");
|
|
596
|
+
if (actualChecksum !== transfer.expectedChecksum) {
|
|
597
|
+
// Notify remote of failure
|
|
598
|
+
this.sendComplete(transfer.remoteNode, sessionId, false, `Checksum mismatch: expected ${transfer.expectedChecksum}, got ${actualChecksum}`);
|
|
599
|
+
this.completePending(sessionId, {
|
|
600
|
+
success: false,
|
|
601
|
+
bytesTransferred: 0,
|
|
602
|
+
error: `Checksum mismatch`,
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Write file
|
|
608
|
+
await mkdir(path.dirname(transfer.targetPath), { recursive: true });
|
|
609
|
+
await writeFile(transfer.targetPath, assembled);
|
|
610
|
+
|
|
611
|
+
// Notify remote of success
|
|
612
|
+
this.sendComplete(transfer.remoteNode, sessionId, true, undefined, assembled.length);
|
|
613
|
+
this.completePending(sessionId, { success: true, bytesTransferred: assembled.length });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private sendComplete(to: string, sessionId: string, success: boolean, error?: string, bytesTransferred?: number): void {
|
|
617
|
+
this.peerManager.sendTo(to, {
|
|
618
|
+
type: "file_transfer_complete",
|
|
619
|
+
id: sessionId,
|
|
620
|
+
from: this.nodeId,
|
|
621
|
+
to,
|
|
622
|
+
timestamp: Date.now(),
|
|
623
|
+
payload: { sessionId, success, error, bytesTransferred },
|
|
624
|
+
} as FileTransferComplete);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private completePending(sessionId: string, result: TransferResult): void {
|
|
628
|
+
const transfer = this.pending.get(sessionId);
|
|
629
|
+
if (!transfer) return;
|
|
630
|
+
clearTimeout(transfer.timer);
|
|
631
|
+
this.pending.delete(sessionId);
|
|
632
|
+
transfer.resolve(result);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private cleanupReceiving(sessionId: string): void {
|
|
636
|
+
const receiving = this.receiving.get(sessionId);
|
|
637
|
+
if (!receiving) return;
|
|
638
|
+
clearTimeout(receiving.timer);
|
|
639
|
+
this.receiving.delete(sessionId);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private createTimer(sessionId: string, mapType: "pending" | "receiving"): ReturnType<typeof setTimeout> {
|
|
643
|
+
return setTimeout(() => {
|
|
644
|
+
if (mapType === "pending") {
|
|
645
|
+
const transfer = this.pending.get(sessionId);
|
|
646
|
+
if (transfer) {
|
|
647
|
+
this.pending.delete(sessionId);
|
|
648
|
+
transfer.reject(new Error("File transfer timeout"));
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
this.receiving.delete(sessionId);
|
|
652
|
+
}
|
|
653
|
+
}, this.config.timeout);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private resetTimer(sessionId: string, mapType: "pending" | "receiving"): void {
|
|
657
|
+
if (mapType === "pending") {
|
|
658
|
+
const transfer = this.pending.get(sessionId);
|
|
659
|
+
if (transfer) {
|
|
660
|
+
clearTimeout(transfer.timer);
|
|
661
|
+
transfer.timer = this.createTimer(sessionId, "pending");
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
const receiving = this.receiving.get(sessionId);
|
|
665
|
+
if (receiving) {
|
|
666
|
+
clearTimeout(receiving.timer);
|
|
667
|
+
receiving.timer = this.createTimer(sessionId, "receiving");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
package/src/health-tracker.ts
CHANGED
|
@@ -132,12 +132,17 @@ export class HealthTracker {
|
|
|
132
132
|
// ── Event recording ─────────────────────────────────────────
|
|
133
133
|
|
|
134
134
|
recordEvent(event: HealthEvent) {
|
|
135
|
+
// Strip undefined values — Automerge rejects them
|
|
136
|
+
const clean: Record<string, unknown> = {};
|
|
137
|
+
for (const [k, v] of Object.entries(event)) {
|
|
138
|
+
if (v !== undefined) clean[k] = v;
|
|
139
|
+
}
|
|
135
140
|
this.doc = Automerge.change(this.doc, (d) => {
|
|
136
141
|
if (!d.nodes[this.nodeId]) {
|
|
137
142
|
d.nodes[this.nodeId] = { events: [], lastUpdated: 0 };
|
|
138
143
|
}
|
|
139
144
|
const entry = d.nodes[this.nodeId]!;
|
|
140
|
-
entry.events.push(
|
|
145
|
+
entry.events.push(clean as HealthEvent);
|
|
141
146
|
entry.lastUpdated = Date.now();
|
|
142
147
|
});
|
|
143
148
|
this.scheduleSave();
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { createClusterDiagnosticTool } from "./tools/cluster-diagnostic.ts";
|
|
|
16
16
|
import { createClusterAcpTool } from "./tools/cluster-acp.ts";
|
|
17
17
|
import { createClusterTerminalTool } from "./tools/cluster-terminal.ts";
|
|
18
18
|
import { createClusterToolInvokeTool } from "./tools/cluster-tool.ts";
|
|
19
|
+
import { createClusterTransferTool } from "./tools/cluster-transfer.ts";
|
|
19
20
|
import { registerClusterCli } from "./cli.ts";
|
|
20
21
|
import { spawnProcess } from "./compat.ts";
|
|
21
22
|
|
|
@@ -302,6 +303,7 @@ const plugin = {
|
|
|
302
303
|
api.registerTool(createClusterAcpTool(), { optional: true });
|
|
303
304
|
api.registerTool(createClusterTerminalTool(), { optional: true });
|
|
304
305
|
api.registerTool(createClusterToolInvokeTool(), { optional: true });
|
|
306
|
+
api.registerTool(createClusterTransferTool(), { optional: true });
|
|
305
307
|
|
|
306
308
|
// Wire up peer approval with OpenClaw channel API
|
|
307
309
|
if (config.peerApproval.enabled) {
|
|
@@ -330,20 +332,23 @@ const plugin = {
|
|
|
330
332
|
|
|
331
333
|
const proc = spawnProcess(
|
|
332
334
|
["openclaw", "gateway", "call", "send", "--json", "--params", JSON.stringify(sendParams)],
|
|
333
|
-
{ stdout: "
|
|
335
|
+
{ stdout: "ignore", stderr: "pipe" },
|
|
334
336
|
);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
337
|
+
// Collect stderr concurrently with waiting for exit to avoid pipe deadlock
|
|
338
|
+
const stderrPromise = (async () => {
|
|
339
|
+
const chunks: Uint8Array[] = [];
|
|
338
340
|
if (proc.stderr) {
|
|
339
341
|
const reader = proc.stderr.getReader();
|
|
340
342
|
while (true) {
|
|
341
343
|
const { done, value } = await reader.read();
|
|
342
344
|
if (done) break;
|
|
343
|
-
|
|
345
|
+
chunks.push(value);
|
|
344
346
|
}
|
|
345
347
|
}
|
|
346
|
-
|
|
348
|
+
return Buffer.concat(chunks).toString("utf-8").trim();
|
|
349
|
+
})();
|
|
350
|
+
const [code, errMsg] = await Promise.all([proc.exited, stderrPromise]);
|
|
351
|
+
if (code !== 0) {
|
|
347
352
|
throw new Error(`gateway send failed (exit ${code}): ${errMsg}`);
|
|
348
353
|
}
|
|
349
354
|
});
|
package/src/knowledge-sync.ts
CHANGED
|
@@ -128,6 +128,8 @@ export class KnowledgeSync {
|
|
|
128
128
|
// ── FS / Watcher ───────────────────────────────────────────────
|
|
129
129
|
private watcher: FSWatcher | null = null;
|
|
130
130
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
131
|
+
private localChangesRunning = false;
|
|
132
|
+
private localChangesQueued = false;
|
|
131
133
|
/** Paths recently written by exportFileToFs — suppress watcher re-trigger. Stores {content, timestamp}. */
|
|
132
134
|
private writtenByExport = new Map<string, { content: string; ts: number }>();
|
|
133
135
|
/** Deferred git commit timer — batches multiple remote syncs into one commit. */
|
|
@@ -474,6 +476,27 @@ export class KnowledgeSync {
|
|
|
474
476
|
}
|
|
475
477
|
|
|
476
478
|
private async handleLocalChanges() {
|
|
479
|
+
// Mutex: if already running, mark queued and return — the running
|
|
480
|
+
// invocation will re-run when it finishes to pick up new changes.
|
|
481
|
+
if (this.localChangesRunning) {
|
|
482
|
+
this.localChangesQueued = true;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.localChangesRunning = true;
|
|
486
|
+
try {
|
|
487
|
+
await this.handleLocalChangesInner();
|
|
488
|
+
} finally {
|
|
489
|
+
this.localChangesRunning = false;
|
|
490
|
+
if (this.localChangesQueued) {
|
|
491
|
+
this.localChangesQueued = false;
|
|
492
|
+
this.handleLocalChanges().catch((err) => {
|
|
493
|
+
debug(TAG, `queued local change handling error: ${err}`);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private async handleLocalChangesInner() {
|
|
477
500
|
// Only process files in pendingChanges (incremental)
|
|
478
501
|
const changesToProcess = new Set(this.pendingChanges);
|
|
479
502
|
this.pendingChanges.clear();
|
|
@@ -802,7 +825,13 @@ export class KnowledgeSync {
|
|
|
802
825
|
if (this.isIgnored(relPath)) return;
|
|
803
826
|
|
|
804
827
|
const content = doc.content ?? "";
|
|
805
|
-
const absPath = path.
|
|
828
|
+
const absPath = path.resolve(this.opts.workspacePath, relPath);
|
|
829
|
+
|
|
830
|
+
// Prevent path traversal (e.g. relPath = "../../etc/passwd")
|
|
831
|
+
if (!absPath.startsWith(this.opts.workspacePath + path.sep) && absPath !== this.opts.workspacePath) {
|
|
832
|
+
debug(TAG, `blocked path traversal attempt: ${relPath}`);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
806
835
|
|
|
807
836
|
let currentContent: string | null = null;
|
|
808
837
|
try {
|
package/src/peer-manager.ts
CHANGED
|
@@ -46,6 +46,9 @@ const SKIP_DEDUP_TYPES = new Set([
|
|
|
46
46
|
// Terminal
|
|
47
47
|
"terminal_open_res", "terminal_data", "terminal_resize",
|
|
48
48
|
"terminal_close", "terminal_close_res",
|
|
49
|
+
// File transfer
|
|
50
|
+
"file_transfer_chunk", "file_transfer_chunk_ack",
|
|
51
|
+
"file_transfer_ack", "file_transfer_complete",
|
|
49
52
|
]);
|
|
50
53
|
|
|
51
54
|
/** Classify WebSocket close code into a human-readable reason. */
|
package/src/sentinel-manager.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { fork, type ChildProcess } from "node:child_process";
|
|
10
10
|
import { join, dirname } from "node:path";
|
|
11
|
-
import { existsSync, readFileSync, mkdirSync, openSync } from "node:fs";
|
|
11
|
+
import { existsSync, readFileSync, mkdirSync, openSync, closeSync } from "node:fs";
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
13
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
14
14
|
|
|
@@ -42,6 +42,9 @@ export class SentinelManager {
|
|
|
42
42
|
execArgv: this.resolveExecArgv(),
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// Close the log fd in the parent — the child has its own copy
|
|
46
|
+
closeSync(logFd);
|
|
47
|
+
|
|
45
48
|
// Send config to sentinel via IPC (includes gateway PID for health checks)
|
|
46
49
|
// If sentinel has no explicit listenPort but the gateway is a listener,
|
|
47
50
|
// inherit the gateway's port for automatic takeover when gateway dies.
|
|
@@ -83,7 +86,7 @@ export class SentinelManager {
|
|
|
83
86
|
}, 1000);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
stop() {
|
|
89
|
+
async stop() {
|
|
87
90
|
// IPC is disconnected shortly after start, so use PID file for shutdown
|
|
88
91
|
if (existsSync(this.pidFile)) {
|
|
89
92
|
try {
|
|
@@ -92,13 +95,11 @@ export class SentinelManager {
|
|
|
92
95
|
process.kill(pid, "SIGTERM");
|
|
93
96
|
// Wait briefly for the process to exit so the next start()
|
|
94
97
|
// doesn't race with a still-dying sentinel
|
|
95
|
-
|
|
96
|
-
while (Date.now() < deadline) {
|
|
98
|
+
for (let i = 0; i < 60; i++) {
|
|
97
99
|
try {
|
|
98
100
|
process.kill(pid, 0);
|
|
99
|
-
// Still alive —
|
|
100
|
-
|
|
101
|
-
while (Date.now() < waitUntil) { /* spin */ }
|
|
101
|
+
// Still alive — async wait
|
|
102
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
102
103
|
} catch {
|
|
103
104
|
break; // exited
|
|
104
105
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -109,7 +109,8 @@ export class TerminalManager {
|
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
//
|
|
112
|
+
// TODO(security): allowFrom 为空时默认允许所有已认证 peer 打开终端会话。
|
|
113
|
+
// 当前仅用于受信任网络。开放前需改为默认拒绝或要求显式配置。
|
|
113
114
|
if (termConfig?.allowFrom && termConfig.allowFrom.length > 0) {
|
|
114
115
|
if (!termConfig.allowFrom.includes(frame.from)) {
|
|
115
116
|
this.peerManager.sendTo(frame.from, {
|
|
@@ -88,6 +88,8 @@ export function createClusterDiagnosticTool(): AnyAgentTool {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// TODO(security): exec 允许任何已认证 peer 在远程 sentinel 执行任意命令,无 allowlist 或 capability check。
|
|
92
|
+
// 当前仅用于受信任网络。开放前需添加命令白名单或 per-peer 授权。
|
|
91
93
|
if (action === "exec") {
|
|
92
94
|
if (!command) {
|
|
93
95
|
return {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterTransferTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_transfer",
|
|
7
|
+
label: "Cluster File Transfer",
|
|
8
|
+
description:
|
|
9
|
+
"Transfer a file between the local node and a remote cluster node. " +
|
|
10
|
+
"Supports large files (up to 100MB) with chunked transfer and SHA-256 integrity check. " +
|
|
11
|
+
"Specify source_node to pull from remote, or target_node to push to remote.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
source_node: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Source nodeId (omit for local). Exactly one of source_node or target_node must be provided.",
|
|
18
|
+
},
|
|
19
|
+
source_path: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "File path on the source node",
|
|
22
|
+
},
|
|
23
|
+
target_node: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Target nodeId (omit for local). Exactly one of source_node or target_node must be provided.",
|
|
26
|
+
},
|
|
27
|
+
target_path: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "File path on the target node",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
required: ["source_path", "target_path"],
|
|
33
|
+
},
|
|
34
|
+
async execute(_toolCallId, params) {
|
|
35
|
+
const { source_node, source_path, target_node, target_path } = params as {
|
|
36
|
+
source_node?: string;
|
|
37
|
+
source_path: string;
|
|
38
|
+
target_node?: string;
|
|
39
|
+
target_path: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Validate: exactly one of source_node or target_node must be provided
|
|
43
|
+
if (source_node && target_node) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text" as const, text: "Error: Provide either source_node or target_node, not both." }],
|
|
46
|
+
details: { error: true },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (!source_node && !target_node) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text" as const, text: "Error: Provide either source_node (to pull) or target_node (to push)." }],
|
|
52
|
+
details: { error: true },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const runtime = getClusterRuntime();
|
|
58
|
+
const ftm = runtime.fileTransferManager;
|
|
59
|
+
if (!ftm) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text" as const, text: "Error: File transfer is not enabled on this node." }],
|
|
62
|
+
details: { error: true },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let result;
|
|
67
|
+
if (source_node) {
|
|
68
|
+
// Pull: remote → local
|
|
69
|
+
result = await ftm.pullFile(source_node, source_path, target_path);
|
|
70
|
+
} else {
|
|
71
|
+
// Push: local → remote
|
|
72
|
+
result = await ftm.pushFile(target_node!, source_path, target_path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const text = result.success
|
|
76
|
+
? `Transfer complete: ${result.bytesTransferred} bytes transferred.`
|
|
77
|
+
: `Transfer failed: ${result.error}`;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text" as const, text }],
|
|
81
|
+
details: result,
|
|
82
|
+
};
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: "text" as const, text: `Transfer error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
86
|
+
details: { error: true },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -317,6 +317,68 @@ export interface ToolBatchResponse extends ClusterFrame {
|
|
|
317
317
|
};
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// ── File transfer ─────────────────────────────────────────────────
|
|
321
|
+
export interface FileTransferInit extends ClusterFrame {
|
|
322
|
+
type: "file_transfer_init";
|
|
323
|
+
id: string;
|
|
324
|
+
payload: {
|
|
325
|
+
sessionId: string;
|
|
326
|
+
direction: "push" | "pull";
|
|
327
|
+
filePath: string;
|
|
328
|
+
targetPath: string;
|
|
329
|
+
fileSize: number;
|
|
330
|
+
totalChunks: number;
|
|
331
|
+
chunkSize: number;
|
|
332
|
+
checksum: string; // SHA-256 hex
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface FileTransferAck extends ClusterFrame {
|
|
337
|
+
type: "file_transfer_ack";
|
|
338
|
+
id: string;
|
|
339
|
+
payload: {
|
|
340
|
+
sessionId: string;
|
|
341
|
+
accepted: boolean;
|
|
342
|
+
error?: string;
|
|
343
|
+
// Pull mode: responder includes file metadata
|
|
344
|
+
fileSize?: number;
|
|
345
|
+
totalChunks?: number;
|
|
346
|
+
checksum?: string;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface FileTransferChunk extends ClusterFrame {
|
|
351
|
+
type: "file_transfer_chunk";
|
|
352
|
+
id: string;
|
|
353
|
+
payload: {
|
|
354
|
+
sessionId: string;
|
|
355
|
+
chunkIndex: number;
|
|
356
|
+
data: string; // base64-encoded
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export interface FileTransferChunkAck extends ClusterFrame {
|
|
361
|
+
type: "file_transfer_chunk_ack";
|
|
362
|
+
id: string;
|
|
363
|
+
payload: {
|
|
364
|
+
sessionId: string;
|
|
365
|
+
chunkIndex: number;
|
|
366
|
+
success: boolean;
|
|
367
|
+
error?: string;
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface FileTransferComplete extends ClusterFrame {
|
|
372
|
+
type: "file_transfer_complete";
|
|
373
|
+
id: string;
|
|
374
|
+
payload: {
|
|
375
|
+
sessionId: string;
|
|
376
|
+
success: boolean;
|
|
377
|
+
error?: string;
|
|
378
|
+
bytesTransferred?: number;
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
320
382
|
// ── Device info ───────────────────────────────────────────────────
|
|
321
383
|
export interface DeviceInfo {
|
|
322
384
|
os: string; // e.g. "Darwin 24.6.0", "Linux 6.1.0"
|
|
@@ -419,6 +481,24 @@ export interface HealthSyncFrame extends ClusterFrame {
|
|
|
419
481
|
};
|
|
420
482
|
}
|
|
421
483
|
|
|
484
|
+
export interface AvailabilityRequest extends ClusterFrame {
|
|
485
|
+
type: "availability_req";
|
|
486
|
+
id: string;
|
|
487
|
+
payload: {
|
|
488
|
+
range: "24h" | "7d" | "90d";
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export interface AvailabilityResponse extends ClusterFrame {
|
|
493
|
+
type: "availability_res";
|
|
494
|
+
id: string;
|
|
495
|
+
payload: {
|
|
496
|
+
success: boolean;
|
|
497
|
+
data?: unknown;
|
|
498
|
+
error?: string;
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
422
502
|
// ── Diagnostic (sentinel) ────────────────────────────────────────
|
|
423
503
|
export interface DiagnosticExec extends ClusterFrame {
|
|
424
504
|
type: "diagnostic_exec";
|
|
@@ -840,4 +920,11 @@ export type AnyClusterFrame =
|
|
|
840
920
|
| TerminalResize
|
|
841
921
|
| TerminalCloseRequest
|
|
842
922
|
| TerminalCloseResponse
|
|
843
|
-
| HealthSyncFrame
|
|
923
|
+
| HealthSyncFrame
|
|
924
|
+
| AvailabilityRequest
|
|
925
|
+
| AvailabilityResponse
|
|
926
|
+
| FileTransferInit
|
|
927
|
+
| FileTransferAck
|
|
928
|
+
| FileTransferChunk
|
|
929
|
+
| FileTransferChunkAck
|
|
930
|
+
| FileTransferComplete;
|