clawmatrix 0.2.11 → 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 +407 -68
- package/src/cli.ts +478 -10
- package/src/cluster-service.ts +114 -14
- package/src/compat.ts +0 -6
- package/src/config.ts +8 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +64 -14
- package/src/handoff.ts +21 -8
- package/src/index.ts +234 -5
- package/src/knowledge-sync.ts +44 -6
- package/src/model-proxy.ts +35 -10
- package/src/peer-manager.ts +81 -13
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +51 -0
- package/src/sentinel.ts +13 -3
- package/src/tool-proxy.ts +12 -4
- package/src/tools/cluster-diagnostic.ts +3 -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-write.ts +3 -1
- package/src/types.ts +103 -1
- package/src/web.ts +2 -10
- package/src/web-ui.ts +0 -1622
package/src/cluster-service.ts
CHANGED
|
@@ -102,6 +102,9 @@ export class ClusterRuntime {
|
|
|
102
102
|
private logger: PluginLogger;
|
|
103
103
|
private openclawConfig: OpenClawConfig;
|
|
104
104
|
private exitHandler: (() => void) | null = null;
|
|
105
|
+
// Pre-built indexes for O(1) local agent lookup
|
|
106
|
+
private agentById = new Map<string, ClawMatrixConfig["agents"][number]>();
|
|
107
|
+
private agentsByTag = new Map<string, ClawMatrixConfig["agents"][number]>();
|
|
105
108
|
|
|
106
109
|
constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
|
|
107
110
|
this.config = config;
|
|
@@ -123,9 +126,14 @@ export class ClusterRuntime {
|
|
|
123
126
|
nodeId: config.nodeId,
|
|
124
127
|
peerManager: this.peerManager,
|
|
125
128
|
});
|
|
129
|
+
// Build agent indexes
|
|
130
|
+
for (const a of config.agents) {
|
|
131
|
+
this.agentById.set(a.id, a);
|
|
132
|
+
for (const t of a.tags) this.agentsByTag.set(t, a);
|
|
133
|
+
}
|
|
126
134
|
}
|
|
127
135
|
|
|
128
|
-
start() {
|
|
136
|
+
async start() {
|
|
129
137
|
// Wire up frame dispatch
|
|
130
138
|
this.peerManager.on("frame", (frame) => {
|
|
131
139
|
this.dispatchFrame(frame);
|
|
@@ -207,14 +215,31 @@ export class ClusterRuntime {
|
|
|
207
215
|
this.logger.error(`[clawmatrix] Health tracker failed to start: ${err}`);
|
|
208
216
|
});
|
|
209
217
|
|
|
210
|
-
// Start subsystems
|
|
211
|
-
this.peerManager.start();
|
|
212
|
-
this.modelProxy.start();
|
|
213
|
-
|
|
214
218
|
// Sentinel: detached subprocess for diagnostics when gateway dies.
|
|
215
219
|
// Default on; starts when not explicitly disabled AND (has outbound peers OR gateway is a listener for port takeover)
|
|
216
|
-
|
|
220
|
+
const sentinelEnabled = (this.config.sentinel?.enabled ?? true) && (this.config.peers.length > 0 || this.config.listen);
|
|
221
|
+
if (sentinelEnabled) {
|
|
217
222
|
this.sentinelManager = new SentinelManager(this.config);
|
|
223
|
+
// Kill old sentinel and wait for the listen port to be released
|
|
224
|
+
// before PeerManager tries to bind it.
|
|
225
|
+
await this.sentinelManager.ensurePortFree();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Start subsystems (port is now guaranteed free)
|
|
229
|
+
this.peerManager.start();
|
|
230
|
+
this.modelProxy.start();
|
|
231
|
+
|
|
232
|
+
// Fetch tool catalog from the local gateway (non-blocking).
|
|
233
|
+
// The catalog (tool names, descriptions) is advertised to peers via peer_sync
|
|
234
|
+
// so remote LLM callers can discover available tools.
|
|
235
|
+
if (this.config.toolProxy?.enabled) {
|
|
236
|
+
this.fetchToolCatalog().catch((err) => {
|
|
237
|
+
this.logger.warn(`[clawmatrix] Tool catalog fetch failed (non-fatal): ${err}`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Spawn the new sentinel after PeerManager is listening
|
|
242
|
+
if (sentinelEnabled && this.sentinelManager) {
|
|
218
243
|
this.sentinelManager.start();
|
|
219
244
|
this.logger.info(`[clawmatrix] Sentinel started for node "${this.config.nodeId}"`);
|
|
220
245
|
}
|
|
@@ -261,6 +286,61 @@ export class ClusterRuntime {
|
|
|
261
286
|
this.modelProxy.updateDiscoveredModels(peers);
|
|
262
287
|
}
|
|
263
288
|
|
|
289
|
+
/** Fetch tool catalog from the local OpenClaw gateway and advertise to peers. */
|
|
290
|
+
private async fetchToolCatalog() {
|
|
291
|
+
const { spawnProcess } = await import("./compat.ts");
|
|
292
|
+
const proc = spawnProcess(
|
|
293
|
+
["openclaw", "gateway", "call", "tools.catalog", "--json", "--params", '{"includePlugins":true}'],
|
|
294
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
295
|
+
);
|
|
296
|
+
const chunks: Uint8Array[] = [];
|
|
297
|
+
if (proc.stdout) {
|
|
298
|
+
const reader = proc.stdout.getReader();
|
|
299
|
+
while (true) {
|
|
300
|
+
const { done, value } = await reader.read();
|
|
301
|
+
if (done) break;
|
|
302
|
+
chunks.push(value);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const code = await proc.exited;
|
|
306
|
+
if (code !== 0) return;
|
|
307
|
+
|
|
308
|
+
const stdout = Buffer.concat(chunks).toString("utf-8").trim();
|
|
309
|
+
if (!stdout) return;
|
|
310
|
+
// stdout may contain non-JSON log lines (e.g. "[plugins] ...") before the actual JSON.
|
|
311
|
+
// Extract the first JSON object from the output.
|
|
312
|
+
const jsonStart = stdout.indexOf("{");
|
|
313
|
+
if (jsonStart < 0) return;
|
|
314
|
+
const data = JSON.parse(stdout.slice(jsonStart)) as {
|
|
315
|
+
groups?: Array<{
|
|
316
|
+
tools: Array<{ id: string; label: string; description: string }>;
|
|
317
|
+
}>;
|
|
318
|
+
};
|
|
319
|
+
if (!data.groups) return;
|
|
320
|
+
|
|
321
|
+
const allowSet = new Set(this.config.toolProxy?.allow ?? []);
|
|
322
|
+
const isWildcard = allowSet.has("*") || allowSet.size === 0;
|
|
323
|
+
const denySet = new Set(this.config.toolProxy?.deny ?? []);
|
|
324
|
+
const catalog: import("./types.ts").ToolCatalogEntry[] = [];
|
|
325
|
+
for (const group of data.groups) {
|
|
326
|
+
for (const tool of group.tools) {
|
|
327
|
+
if (denySet.has(tool.id)) continue;
|
|
328
|
+
if (!isWildcard && !allowSet.has(tool.id)) continue;
|
|
329
|
+
// Skip clawmatrix's own cluster_ tools (they're the invoker, not the invokee)
|
|
330
|
+
if (tool.id.startsWith("cluster_")) continue;
|
|
331
|
+
catalog.push({
|
|
332
|
+
name: tool.id,
|
|
333
|
+
description: tool.description,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (catalog.length > 0) {
|
|
339
|
+
this.peerManager.updateToolCatalog(catalog);
|
|
340
|
+
this.logger.info(`[clawmatrix] Tool catalog: ${catalog.length} tool(s) advertised to peers`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
264
344
|
private resolveWorkspacePath(): string | null {
|
|
265
345
|
// Read workspace from OpenClaw agent config (first agent or default agent)
|
|
266
346
|
const agents = (this.openclawConfig as Record<string, unknown>).agents as
|
|
@@ -491,6 +571,29 @@ export class ClusterRuntime {
|
|
|
491
571
|
case "acp_get_modes_res":
|
|
492
572
|
this.acpProxy?.handleGetModesResponse(frame as AcpGetModesResponse);
|
|
493
573
|
break;
|
|
574
|
+
case "acp_set_config":
|
|
575
|
+
if (this.acpProxy) {
|
|
576
|
+
this.acpProxy.handleSetConfigRequest(frame as import("./types.ts").AcpSetConfigRequest).catch((err) => {
|
|
577
|
+
this.logger.error(`[clawmatrix] ACP set config error: ${err}`);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
case "acp_set_config_res":
|
|
582
|
+
this.acpProxy?.handleSetConfigResponse(frame as import("./types.ts").AcpSetConfigResponse);
|
|
583
|
+
break;
|
|
584
|
+
case "acp_subscribe":
|
|
585
|
+
if (this.acpProxy) {
|
|
586
|
+
this.acpProxy.handleSubscribeRequest(frame as import("./types.ts").AcpSubscribeRequest).catch((err) => {
|
|
587
|
+
this.logger.error(`[clawmatrix] ACP subscribe error: ${err}`);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
case "acp_unsubscribe":
|
|
592
|
+
this.acpProxy?.handleUnsubscribeRequest(frame as import("./types.ts").AcpUnsubscribeRequest);
|
|
593
|
+
break;
|
|
594
|
+
case "acp_session_notify":
|
|
595
|
+
// Outbound notification — no server-side handling needed.
|
|
596
|
+
break;
|
|
494
597
|
case "chat_history_req":
|
|
495
598
|
if (this.acpProxy) {
|
|
496
599
|
this.acpProxy.handleChatHistoryRequest(frame as ChatHistoryRequest).catch((err) => {
|
|
@@ -544,12 +647,9 @@ export class ClusterRuntime {
|
|
|
544
647
|
private handleSendMessage(frame: SendMessage) {
|
|
545
648
|
// Inject message into local agent session via openclaw CLI
|
|
546
649
|
const { target, message } = frame.payload;
|
|
547
|
-
const agent =
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
return a.id === target;
|
|
552
|
-
});
|
|
650
|
+
const agent = target.startsWith("tags:")
|
|
651
|
+
? this.agentsByTag.get(target.slice(5))
|
|
652
|
+
: this.agentById.get(target);
|
|
553
653
|
|
|
554
654
|
if (!agent) {
|
|
555
655
|
this.logger.warn(
|
|
@@ -587,9 +687,9 @@ export function createClusterService(
|
|
|
587
687
|
): OpenClawPluginService {
|
|
588
688
|
return {
|
|
589
689
|
id: "clawmatrix",
|
|
590
|
-
start(ctx: OpenClawPluginServiceContext) {
|
|
690
|
+
async start(ctx: OpenClawPluginServiceContext) {
|
|
591
691
|
clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig, openclawVersion);
|
|
592
|
-
clusterRuntime.start();
|
|
692
|
+
await clusterRuntime.start();
|
|
593
693
|
onStarted?.();
|
|
594
694
|
},
|
|
595
695
|
async stop() {
|
package/src/compat.ts
CHANGED
|
@@ -8,12 +8,6 @@ import { spawn as cpSpawn } from "node:child_process";
|
|
|
8
8
|
import { open, readFile, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
|
|
11
|
-
export interface SpawnResult {
|
|
12
|
-
exitCode: number;
|
|
13
|
-
stdout: string;
|
|
14
|
-
stderr: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
11
|
/** Spawn a subprocess and collect stdout/stderr. */
|
|
18
12
|
export function spawnProcess(
|
|
19
13
|
cmd: string[],
|
package/src/config.ts
CHANGED
|
@@ -161,17 +161,20 @@ const RawClawMatrixConfigSchema = z.object({
|
|
|
161
161
|
secret: z.string().min(16, "secret must be at least 16 characters"),
|
|
162
162
|
listen: z.boolean().default(false),
|
|
163
163
|
listenHost: z.string().default("0.0.0.0"),
|
|
164
|
-
listenPort: z.number().default(0),
|
|
164
|
+
listenPort: z.number().int().min(0).max(65535).default(0),
|
|
165
165
|
peers: z.array(PeerConfigSchema).default([]),
|
|
166
166
|
agents: z.array(AgentInfoSchema).default([]),
|
|
167
167
|
models: z.array(ModelInfoSchema).default([]),
|
|
168
168
|
proxyModels: z.array(ProxyModelGroupSchema).default([]),
|
|
169
169
|
tags: z.array(z.string()).default([]),
|
|
170
|
-
proxyPort: z.number().default(0),
|
|
170
|
+
proxyPort: z.number().int().min(0).max(65535).default(0),
|
|
171
171
|
toolProxy: ToolProxyConfigSchema.optional(),
|
|
172
|
-
handoffTimeout: z.number().default(600_000),
|
|
173
|
-
modelTimeout: z.number().default(120_000),
|
|
174
|
-
toolTimeout: z.number().default(30_000),
|
|
172
|
+
handoffTimeout: z.number().positive().default(600_000),
|
|
173
|
+
modelTimeout: z.number().positive().default(120_000),
|
|
174
|
+
toolTimeout: z.number().positive().default(30_000),
|
|
175
|
+
/** Grace period (ms) before broadcasting peer_leave after disconnect.
|
|
176
|
+
* Allows brief reconnections (WiFi/cellular handoff) to be invisible to the mesh. */
|
|
177
|
+
disconnectGrace: z.number().nonnegative().default(30_000),
|
|
175
178
|
sentinel: SentinelConfigSchema,
|
|
176
179
|
web: WebConfigSchema,
|
|
177
180
|
knowledge: KnowledgeConfigSchema,
|
package/src/connection.ts
CHANGED
|
@@ -485,70 +485,76 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
485
485
|
|
|
486
486
|
private startHeartbeat() {
|
|
487
487
|
this.lastReceivedAt = Date.now();
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
this.heartbeatTimer = setTimeout(() => {
|
|
491
|
-
if (this.closed) return;
|
|
492
|
-
|
|
493
|
-
// Watchdog: if no data received for a long time, the connection is dead
|
|
494
|
-
// regardless of what the heartbeat ping/pong state says.
|
|
495
|
-
const silenceMs = Date.now() - this.lastReceivedAt;
|
|
496
|
-
if (this.lastReceivedAt > 0 && silenceMs > Connection.RECEIVE_TIMEOUT) {
|
|
497
|
-
debug("heartbeat", `No data received for ${Math.round(silenceMs / 1000)}s from ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
498
|
-
this.close(4002, "receive timeout");
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
488
|
+
this.scheduleHeartbeatTick();
|
|
489
|
+
}
|
|
501
490
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
debug("heartbeat", `${HEARTBEAT_TIMEOUT_COUNT} missed pongs from ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
507
|
-
this.close(4002, "heartbeat timeout");
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
491
|
+
private scheduleHeartbeatTick() {
|
|
492
|
+
const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
|
|
493
|
+
this.heartbeatTimer = setTimeout(this.heartbeatTick, interval);
|
|
494
|
+
}
|
|
510
495
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
try {
|
|
514
|
-
if (this.transport.readyState !== WebSocket.OPEN) {
|
|
515
|
-
debug("heartbeat", `Transport not open (state=${this.transport.readyState}) for ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
516
|
-
this.close(4002, "transport closed");
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
this.lastPingSentAt = Date.now();
|
|
520
|
-
this.send({
|
|
521
|
-
type: "ping",
|
|
522
|
-
from: this.nodeId,
|
|
523
|
-
timestamp: this.lastPingSentAt,
|
|
524
|
-
} as AnyClusterFrame);
|
|
525
|
-
} catch (err) {
|
|
526
|
-
debug("heartbeat", `Ping send failed for ${this.remoteNodeId ?? "unknown"}: ${err}`);
|
|
527
|
-
this.close(4002, "ping send failed");
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
496
|
+
private heartbeatTick = () => {
|
|
497
|
+
if (this.closed) return;
|
|
530
498
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
499
|
+
// Watchdog: if no data received for a long time, the connection is dead
|
|
500
|
+
// regardless of what the heartbeat ping/pong state says.
|
|
501
|
+
const silenceMs = Date.now() - this.lastReceivedAt;
|
|
502
|
+
if (this.lastReceivedAt > 0 && silenceMs > Connection.RECEIVE_TIMEOUT) {
|
|
503
|
+
debug("heartbeat", `No data received for ${Math.round(silenceMs / 1000)}s from ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
504
|
+
this.close(4002, "receive timeout");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Increment before checking: this ping is about to be sent and
|
|
509
|
+
// counts as outstanding until a pong arrives.
|
|
510
|
+
this.missedPongs++;
|
|
511
|
+
if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
|
|
512
|
+
debug("heartbeat", `${HEARTBEAT_TIMEOUT_COUNT} missed pongs from ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
513
|
+
this.close(4002, "heartbeat timeout");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Send ping — wrapped in try-catch to prevent breaking the heartbeat chain.
|
|
518
|
+
// If send fails, the connection is dead; close it.
|
|
519
|
+
try {
|
|
520
|
+
if (this.transport.readyState !== WebSocket.OPEN) {
|
|
521
|
+
debug("heartbeat", `Transport not open (state=${this.transport.readyState}) for ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
522
|
+
this.close(4002, "transport closed");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
this.lastPingSentAt = Date.now();
|
|
526
|
+
this.send({
|
|
527
|
+
type: "ping",
|
|
528
|
+
from: this.nodeId,
|
|
529
|
+
timestamp: this.lastPingSentAt,
|
|
530
|
+
} as AnyClusterFrame);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
debug("heartbeat", `Ping send failed for ${this.remoteNodeId ?? "unknown"}: ${err}`);
|
|
533
|
+
this.close(4002, "ping send failed");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
this.scheduleHeartbeatTick();
|
|
538
|
+
};
|
|
536
539
|
|
|
537
540
|
// ── Dummy traffic (breaks heartbeat timing pattern) ────────────
|
|
538
541
|
private startDummyTraffic() {
|
|
539
542
|
if (!this.sessionKey) return;
|
|
540
|
-
|
|
541
|
-
// Random interval 2-8 seconds — interleaves with heartbeat to obscure pattern
|
|
542
|
-
const interval = 2_000 + Math.random() * 6_000;
|
|
543
|
-
this.dummyTimer = setTimeout(() => {
|
|
544
|
-
if (this.closed || !this.sessionKey) return;
|
|
545
|
-
this.send({ type: "_d", from: "", timestamp: 0 } as unknown as AnyClusterFrame);
|
|
546
|
-
scheduleNext();
|
|
547
|
-
}, interval);
|
|
548
|
-
};
|
|
549
|
-
scheduleNext();
|
|
543
|
+
this.scheduleDummyTick();
|
|
550
544
|
}
|
|
551
545
|
|
|
546
|
+
private scheduleDummyTick() {
|
|
547
|
+
// Random interval 2-8 seconds — interleaves with heartbeat to obscure pattern
|
|
548
|
+
const interval = 2_000 + Math.random() * 6_000;
|
|
549
|
+
this.dummyTimer = setTimeout(this.dummyTick, interval);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private dummyTick = () => {
|
|
553
|
+
if (this.closed || !this.sessionKey) return;
|
|
554
|
+
this.send({ type: "_d", from: "", timestamp: 0 } as unknown as AnyClusterFrame);
|
|
555
|
+
this.scheduleDummyTick();
|
|
556
|
+
};
|
|
557
|
+
|
|
552
558
|
// ── Cleanup ────────────────────────────────────────────────────
|
|
553
559
|
close(code = 1000, reason = "normal") {
|
|
554
560
|
if (this.closed) return;
|
package/src/e2e/helpers.ts
CHANGED
|
@@ -23,11 +23,6 @@ export function allocPort(): number {
|
|
|
23
23
|
return nextPort++;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** Reset port counter (call in afterAll if tests run in a loop). */
|
|
27
|
-
export function resetPorts(start = 19500): void {
|
|
28
|
-
nextPort = start;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
// ── Logger ──────────────────────────────────────────────────────────
|
|
32
27
|
|
|
33
28
|
/** Minimal no-op logger satisfying PluginLogger. */
|
|
@@ -110,6 +105,7 @@ function buildConfig(options: TestNodeOptions): ClawMatrixConfig {
|
|
|
110
105
|
handoffTimeout: options.handoffTimeout ?? 600_000,
|
|
111
106
|
modelTimeout: options.modelTimeout ?? 120_000,
|
|
112
107
|
toolTimeout: options.toolTimeout ?? 30_000,
|
|
108
|
+
disconnectGrace: 0,
|
|
113
109
|
peerApproval: {
|
|
114
110
|
enabled: false,
|
|
115
111
|
mode: "notify",
|
package/src/file-transfer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile, stat, mkdir, lstat, realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { debug } from "./debug.ts";
|
|
5
5
|
import type { PeerManager } from "./peer-manager.ts";
|
|
@@ -40,6 +40,7 @@ interface PendingTransfer {
|
|
|
40
40
|
expectedChunks?: number;
|
|
41
41
|
expectedChecksum?: string;
|
|
42
42
|
receivedChunks?: number;
|
|
43
|
+
onProgress?: (progress: TransferProgress) => void;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
interface ReceivingTransfer {
|
|
@@ -59,6 +60,20 @@ interface ReceivingTransfer {
|
|
|
59
60
|
cachedData?: Buffer;
|
|
60
61
|
}
|
|
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
|
+
|
|
62
77
|
export interface TransferResult {
|
|
63
78
|
success: boolean;
|
|
64
79
|
bytesTransferred: number;
|
|
@@ -87,10 +102,10 @@ export class FileTransferManager {
|
|
|
87
102
|
|
|
88
103
|
// ── Public API ──────────────────────────────────────────────────
|
|
89
104
|
|
|
90
|
-
async pushFile(remoteNode: string, localPath: string, remotePath: string): Promise<TransferResult> {
|
|
105
|
+
async pushFile(remoteNode: string, localPath: string, remotePath: string, opts?: TransferOptions): Promise<TransferResult> {
|
|
91
106
|
this.ensureEnabled();
|
|
92
107
|
const resolvedPath = path.resolve(localPath);
|
|
93
|
-
this.validatePath(resolvedPath);
|
|
108
|
+
await this.validatePath(resolvedPath);
|
|
94
109
|
|
|
95
110
|
const fileData = await readFile(resolvedPath);
|
|
96
111
|
if (fileData.length > this.config.maxFileSize) {
|
|
@@ -117,6 +132,7 @@ export class FileTransferManager {
|
|
|
117
132
|
chunkSize: this.config.chunkSize,
|
|
118
133
|
totalChunks,
|
|
119
134
|
sentChunks: 0,
|
|
135
|
+
onProgress: opts?.onProgress,
|
|
120
136
|
});
|
|
121
137
|
|
|
122
138
|
this.peerManager.sendTo(remoteNode, {
|
|
@@ -139,10 +155,10 @@ export class FileTransferManager {
|
|
|
139
155
|
});
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
async pullFile(remoteNode: string, remotePath: string, localPath: string): Promise<TransferResult> {
|
|
158
|
+
async pullFile(remoteNode: string, remotePath: string, localPath: string, opts?: TransferOptions): Promise<TransferResult> {
|
|
143
159
|
this.ensureEnabled();
|
|
144
160
|
const resolvedPath = path.resolve(localPath);
|
|
145
|
-
this.validatePath(resolvedPath);
|
|
161
|
+
await this.validatePath(resolvedPath);
|
|
146
162
|
|
|
147
163
|
const sessionId = crypto.randomUUID();
|
|
148
164
|
|
|
@@ -160,6 +176,7 @@ export class FileTransferManager {
|
|
|
160
176
|
timer,
|
|
161
177
|
chunks: new Map(),
|
|
162
178
|
receivedChunks: 0,
|
|
179
|
+
onProgress: opts?.onProgress,
|
|
163
180
|
});
|
|
164
181
|
|
|
165
182
|
this.peerManager.sendTo(remoteNode, {
|
|
@@ -196,7 +213,7 @@ export class FileTransferManager {
|
|
|
196
213
|
if (direction === "push") {
|
|
197
214
|
// Remote wants to push a file to us
|
|
198
215
|
const resolvedTarget = path.resolve(targetPath);
|
|
199
|
-
if (!this.isPathAllowed(resolvedTarget)) {
|
|
216
|
+
if (!(await this.isPathAllowed(resolvedTarget))) {
|
|
200
217
|
this.sendAck(frame.from, sessionId, frame.id, false, "Target path not allowed");
|
|
201
218
|
return;
|
|
202
219
|
}
|
|
@@ -226,7 +243,7 @@ export class FileTransferManager {
|
|
|
226
243
|
} else {
|
|
227
244
|
// Remote wants to pull a file from us
|
|
228
245
|
const resolvedSource = path.resolve(filePath);
|
|
229
|
-
if (!this.isPathAllowed(resolvedSource)) {
|
|
246
|
+
if (!(await this.isPathAllowed(resolvedSource))) {
|
|
230
247
|
this.sendAck(frame.from, sessionId, frame.id, false, "Source path not allowed");
|
|
231
248
|
return;
|
|
232
249
|
}
|
|
@@ -331,6 +348,16 @@ export class FileTransferManager {
|
|
|
331
348
|
payload: { sessionId, chunkIndex, success: true },
|
|
332
349
|
} as FileTransferChunkAck);
|
|
333
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
|
+
|
|
334
361
|
// Check if all chunks received
|
|
335
362
|
if (pending.receivedChunks === pending.expectedChunks) {
|
|
336
363
|
this.finalizePull(sessionId).catch((err) => {
|
|
@@ -387,6 +414,14 @@ export class FileTransferManager {
|
|
|
387
414
|
return;
|
|
388
415
|
}
|
|
389
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
|
+
});
|
|
390
425
|
if (pending.sentChunks! < pending.totalChunks!) {
|
|
391
426
|
this.sendNextChunk(sessionId);
|
|
392
427
|
}
|
|
@@ -442,13 +477,13 @@ export class FileTransferManager {
|
|
|
442
477
|
}
|
|
443
478
|
|
|
444
479
|
destroy(): void {
|
|
445
|
-
for (const [
|
|
480
|
+
for (const [, transfer] of this.pending) {
|
|
446
481
|
clearTimeout(transfer.timer);
|
|
447
482
|
transfer.reject(new Error("FileTransferManager destroyed"));
|
|
448
483
|
}
|
|
449
484
|
this.pending.clear();
|
|
450
485
|
|
|
451
|
-
for (const [
|
|
486
|
+
for (const [, transfer] of this.receiving) {
|
|
452
487
|
clearTimeout(transfer.timer);
|
|
453
488
|
}
|
|
454
489
|
this.receiving.clear();
|
|
@@ -462,20 +497,35 @@ export class FileTransferManager {
|
|
|
462
497
|
}
|
|
463
498
|
}
|
|
464
499
|
|
|
465
|
-
private validatePath(resolvedPath: string): void {
|
|
466
|
-
if (!this.isPathAllowed(resolvedPath)) {
|
|
500
|
+
private async validatePath(resolvedPath: string): Promise<void> {
|
|
501
|
+
if (!(await this.isPathAllowed(resolvedPath))) {
|
|
467
502
|
throw new Error(`Path not allowed: ${resolvedPath}`);
|
|
468
503
|
}
|
|
469
504
|
}
|
|
470
505
|
|
|
471
|
-
private isPathAllowed(resolvedPath: string): boolean {
|
|
506
|
+
private async isPathAllowed(resolvedPath: string): Promise<boolean> {
|
|
472
507
|
// TODO(security): allowedPaths 为空时默认允许所有路径,当前仅用于受信任网络。
|
|
473
508
|
// 开放到非受信环境前需改为默认拒绝,或要求显式配置 allowedPaths。
|
|
474
509
|
if (this.config.allowedPaths.length === 0) return true;
|
|
475
|
-
|
|
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
|
+
|
|
476
526
|
return this.config.allowedPaths.some((allowed) => {
|
|
477
527
|
const resolvedAllowed = path.resolve(allowed);
|
|
478
|
-
return
|
|
528
|
+
return realResolved === resolvedAllowed || realResolved.startsWith(resolvedAllowed + path.sep);
|
|
479
529
|
});
|
|
480
530
|
}
|
|
481
531
|
|
package/src/handoff.ts
CHANGED
|
@@ -54,16 +54,33 @@ export class HandoffManager {
|
|
|
54
54
|
// Multi-device sync: track which nodes are watching each handoff session (by sessionId)
|
|
55
55
|
private sessionWatchers = new Map<string, Set<string>>();
|
|
56
56
|
|
|
57
|
+
// Pre-built indexes for O(1) agent lookup
|
|
58
|
+
private agentById = new Map<string, ClawMatrixConfig["agents"][number]>();
|
|
59
|
+
private agentsByTag = new Map<string, ClawMatrixConfig["agents"][number]>();
|
|
60
|
+
|
|
57
61
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
|
|
58
62
|
this.config = config;
|
|
59
63
|
this.peerManager = peerManager;
|
|
60
64
|
this.gatewayInfo = gatewayInfo;
|
|
61
65
|
this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
|
|
62
66
|
|
|
67
|
+
// Build agent indexes
|
|
68
|
+
for (const a of config.agents) {
|
|
69
|
+
this.agentById.set(a.id, a);
|
|
70
|
+
for (const t of a.tags) this.agentsByTag.set(t, a);
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
// Periodically clean up stale input_required entries
|
|
64
74
|
this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
private findLocalAgent(target: string): ClawMatrixConfig["agents"][number] | undefined {
|
|
78
|
+
if (target.startsWith("tags:")) {
|
|
79
|
+
return this.agentsByTag.get(target.slice(5));
|
|
80
|
+
}
|
|
81
|
+
return this.agentById.get(target);
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
// ── Multi-device sync helpers ──────────────────────────────────
|
|
68
85
|
|
|
69
86
|
private addSessionWatcher(sessionId: string, nodeId: string) {
|
|
@@ -271,13 +288,7 @@ export class HandoffManager {
|
|
|
271
288
|
const { id, from, payload } = frame;
|
|
272
289
|
|
|
273
290
|
// Find matching local agent
|
|
274
|
-
const agent = this.
|
|
275
|
-
if (payload.target.startsWith("tags:")) {
|
|
276
|
-
const tag = payload.target.slice(5);
|
|
277
|
-
return a.tags.includes(tag);
|
|
278
|
-
}
|
|
279
|
-
return a.id === payload.target;
|
|
280
|
-
});
|
|
291
|
+
const agent = this.findLocalAgent(payload.target);
|
|
281
292
|
|
|
282
293
|
if (!agent) {
|
|
283
294
|
this.peerManager.sendTo(from, {
|
|
@@ -477,6 +488,8 @@ export class HandoffManager {
|
|
|
477
488
|
const decoder = new TextDecoder();
|
|
478
489
|
const chunks: string[] = [];
|
|
479
490
|
let buffer = "";
|
|
491
|
+
// Cache active entry lookup outside the hot loop — stable during the stream
|
|
492
|
+
const activeEntry = this.active.get(handoffId);
|
|
480
493
|
|
|
481
494
|
try {
|
|
482
495
|
while (true) {
|
|
@@ -510,7 +523,6 @@ export class HandoffManager {
|
|
|
510
523
|
|
|
511
524
|
// Broadcast progress to mobile nodes (throttled, detail is just
|
|
512
525
|
// a heartbeat — don't send token-level deltas as they're meaningless fragments)
|
|
513
|
-
const activeEntry = this.active.get(handoffId);
|
|
514
526
|
if (activeEntry) {
|
|
515
527
|
this.taskActivity.broadcast(
|
|
516
528
|
handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
|
|
@@ -605,6 +617,7 @@ export class HandoffManager {
|
|
|
605
617
|
|
|
606
618
|
// If canceled during input_required, no runAgentTurn is running to clean up
|
|
607
619
|
if (wasInputRequired) {
|
|
620
|
+
this.sessionWatchers.delete(entry.sessionId);
|
|
608
621
|
this.active.delete(frame.id);
|
|
609
622
|
}
|
|
610
623
|
|