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.
@@ -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
- if ((this.config.sentinel?.enabled ?? true) && (this.config.peers.length > 0 || this.config.listen)) {
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 = this.config.agents.find((a) => {
548
- if (target.startsWith("tags:")) {
549
- return a.tags.includes(target.slice(5));
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
- const scheduleNext = () => {
489
- const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
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
- // Increment before checking: this ping is about to be sent and
503
- // counts as outstanding until a pong arrives.
504
- this.missedPongs++;
505
- if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
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
- // Send ping wrapped in try-catch to prevent breaking the heartbeat chain.
512
- // If send fails, the connection is dead; close it.
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
- scheduleNext();
532
- }, interval);
533
- };
534
- scheduleNext();
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
- const scheduleNext = () => {
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;
@@ -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",
@@ -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 [id, transfer] of this.pending) {
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 [id, transfer] of this.receiving) {
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
- const normalized = path.resolve(resolvedPath);
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 normalized === resolvedAllowed || normalized.startsWith(resolvedAllowed + path.sep);
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.config.agents.find((a) => {
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