clawmatrix 0.2.5 → 0.2.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/connection.ts CHANGED
@@ -29,6 +29,8 @@ const HEARTBEAT_BASE = 12_000;
29
29
  const HEARTBEAT_JITTER = 6_000;
30
30
  const HEARTBEAT_TIMEOUT_COUNT = 3;
31
31
  const AUTH_TIMEOUT = 10_000;
32
+ /** Extended timeout when server signals approval is pending (must survive human review). */
33
+ const AUTH_PENDING_TIMEOUT = 1_200_000; // 20 minutes
32
34
 
33
35
  export type ConnectionRole = "inbound" | "outbound";
34
36
 
@@ -287,7 +289,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
287
289
  this.clearAuthTimer();
288
290
 
289
291
  if (this.deferAuthOk) {
290
- // Don't send auth_ok yet — wait for completeAuth() after approval
292
+ // Don't send auth_ok yet — wait for completeAuth() after approval.
293
+ // Signal the outbound that auth is pending so it extends its timer
294
+ // instead of timing out after AUTH_TIMEOUT (10s).
295
+ if (this.sessionKey) {
296
+ this.send({ type: "auth_pending", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
297
+ } else {
298
+ this.sendRaw({ p: 1 });
299
+ }
291
300
  this.emit("authenticated", this.remoteCapabilities);
292
301
  return;
293
302
  }
@@ -325,6 +334,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
325
334
  this.clearAuthTimer();
326
335
 
327
336
  if (this.deferAuthOk) {
337
+ // Signal the outbound that auth is pending (legacy plaintext path)
338
+ this.sendRaw({ p: 1 });
328
339
  this.emit("authenticated", this.remoteCapabilities);
329
340
  } else {
330
341
  this.sendAuthOkAndStart();
@@ -379,6 +390,19 @@ export class Connection extends EventEmitter<ConnectionEvents> {
379
390
  return;
380
391
  }
381
392
 
393
+ // auth_pending: server verified HMAC but auth_ok is deferred (peer approval).
394
+ // Extend the auth timer so the connection survives while waiting for human approval.
395
+ if (frame.type === "auth_pending" || (frame.p === 1 && !frame.type)) {
396
+ debug("auth", `Received auth_pending from server, extending auth timeout to ${AUTH_PENDING_TIMEOUT}ms`);
397
+ this.clearAuthTimer();
398
+ this.authTimer = setTimeout(() => {
399
+ if (!this.authenticated) {
400
+ this.close(4003, "auth pending timeout");
401
+ }
402
+ }, AUTH_PENDING_TIMEOUT);
403
+ return;
404
+ }
405
+
382
406
  // auth_ok (decrypted from binary envelope, or plaintext legacy)
383
407
  if (frame.type === "auth_ok") {
384
408
  const ok = frame as AuthOk;
package/src/handoff.ts CHANGED
@@ -137,6 +137,10 @@ export class HandoffManager {
137
137
  throw new Error(`No reachable agent for target "${target}"`);
138
138
  }
139
139
 
140
+ if (route.nodeId === this.config.nodeId) {
141
+ throw new Error(`Target "${target}" resolves to self (${this.config.nodeId}). Cannot handoff to own node.`);
142
+ }
143
+
140
144
  return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES, options?.onStream);
141
145
  }
142
146
 
package/src/index.ts CHANGED
@@ -467,7 +467,9 @@ const plugin = {
467
467
  try {
468
468
  const runtime = getClusterRuntime();
469
469
  const allPeers = runtime.peerManager.router.getAllPeers();
470
- respond(true, mergeSentinelPeers(allPeers, runtime));
470
+ const filtered = mergeSentinelPeers(allPeers, runtime)
471
+ .filter((p) => (p as { nodeId: string }).nodeId !== config.nodeId);
472
+ respond(true, filtered);
471
473
  } catch {
472
474
  respond(true, []);
473
475
  }
@@ -633,7 +635,8 @@ const plugin = {
633
635
  api.on("before_prompt_build", () => {
634
636
  try {
635
637
  const runtime = getClusterRuntime();
636
- const peerCount = runtime.peerManager.router.getAllPeers().length;
638
+ const peerCount = runtime.peerManager.router.getAllPeers()
639
+ .filter((p) => p.nodeId !== config.nodeId).length;
637
640
 
638
641
  // Rebuild system context only when peer count changes
639
642
  if (peerCount !== cachedPeerCount) {
@@ -643,7 +646,7 @@ const plugin = {
643
646
  lines.push("[ClawMatrix] No peers online. Use cluster_peers to check cluster status.");
644
647
  } else {
645
648
  lines.push(
646
- `[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}`,
649
+ `[ClawMatrix Cluster] YOU ARE node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}. This is YOUR identity — never target yourself with cluster tools.`,
647
650
  ...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
648
651
  `${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
649
652
  "Prefer cluster_tool for device-specific tools (screenshot, battery, etc.); cluster_exec/read/write for file/shell ops; cluster_handoff for complex multi-step tasks.",
@@ -387,6 +387,14 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
387
387
  // Peer's persistent public key for TOFU identity binding
388
388
  const peerPublicKey = conn.remoteIdentityKey ?? undefined;
389
389
 
390
+ // Prevent self-connection: if outbound connection authenticated as our own nodeId
391
+ // (e.g. peer URL accidentally points to self), close immediately.
392
+ if (conn.role === "outbound" && nodeId === this.config.nodeId) {
393
+ debug("peer", `Self-connection detected (outbound to ${nodeId}), closing`);
394
+ conn.close(4002, "self-connection");
395
+ return;
396
+ }
397
+
390
398
  // Peer approval check (inbound only — outbound peers are explicitly configured)
391
399
  // Skip approval for same-nodeId connections from localhost (local clients
392
400
  // like Mac desktop app / iOS simulator). An attacker would need to already
@@ -540,6 +548,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
540
548
 
541
549
  // ── Message handling ───────────────────────────────────────────
542
550
  private onFrame(frame: AnyClusterFrame, from: Connection) {
551
+ // Ignore self-echo: frames with our own nodeId that were relayed back to us
552
+ if (frame.from === this.config.nodeId) return;
553
+
543
554
  // Validate from field: must be the direct peer or a known node (relayed)
544
555
  if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) {
545
556
  debug("peer", `dropped frame type=${frame.type} from=${frame.from}: unknown origin (via ${from.remoteNodeId})`);
@@ -691,6 +702,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
691
702
  changed = true;
692
703
  }
693
704
  } else {
705
+ // Skip if the remote peer only knows about this node through us —
706
+ // using them as relay would create a routing loop.
707
+ if (peer.reachableVia === this.config.nodeId) continue;
694
708
  const existing = this.router.getRoute(peer.nodeId);
695
709
  if (!existing) changed = true;
696
710
  this.router.addRelayPeer(peer, from.remoteNodeId!);
package/src/router.ts CHANGED
@@ -97,6 +97,9 @@ export class Router {
97
97
  const relayRoute = this.routes.get(viaNodeId);
98
98
  const estimatedLatency = (relayRoute?.latencyMs ?? 0) + (peer.latencyMs ?? 0);
99
99
 
100
+ // Don't overwrite a better relay route with a worse one (allow equal for capability updates)
101
+ if (existing?.reachableVia && existing.latencyMs < estimatedLatency) return;
102
+
100
103
  this.routes.set(peer.nodeId, {
101
104
  nodeId: peer.nodeId,
102
105
  agents: peer.agents,
@@ -164,6 +167,8 @@ export class Router {
164
167
 
165
168
  let candidates: RouteEntry[] = [];
166
169
  for (const entry of this.routes.values()) {
170
+ // Skip self — never resolve to our own node
171
+ if (entry.nodeId === this.nodeId) continue;
167
172
  if (isTagQuery) {
168
173
  if (entry.agents.some((a) => a.tags.includes(tag!)) || entry.tags.includes(tag!)) {
169
174
  candidates.push(entry);
@@ -178,7 +183,7 @@ export class Router {
178
183
  // Fallback: if no agent ID or tag matched, try matching by nodeId
179
184
  if (candidates.length === 0 && !isTagQuery) {
180
185
  const byNode = this.routes.get(target);
181
- if (byNode) candidates.push(byNode);
186
+ if (byNode && byNode.nodeId !== this.nodeId) candidates.push(byNode);
182
187
  }
183
188
 
184
189
  if (candidates.length === 0) return undefined;
@@ -201,6 +206,8 @@ export class Router {
201
206
  const tag = target.slice(5);
202
207
  const candidates: RouteEntry[] = [];
203
208
  for (const entry of this.routes.values()) {
209
+ // Skip self — never resolve to our own node
210
+ if (entry.nodeId === this.nodeId) continue;
204
211
  if (entry.tags.includes(tag)) {
205
212
  candidates.push(entry);
206
213
  }
@@ -215,6 +222,8 @@ export class Router {
215
222
  });
216
223
  return candidates[0];
217
224
  }
225
+ // Skip self — never resolve to our own node
226
+ if (target === this.nodeId) return undefined;
218
227
  return this.routes.get(target);
219
228
  }
220
229
 
package/src/tool-proxy.ts CHANGED
@@ -180,7 +180,7 @@ export class ToolProxy {
180
180
  to: string,
181
181
  payload: ToolProxyResponse["payload"],
182
182
  ) {
183
- this.peerManager.sendTo(to, {
183
+ const sent = this.peerManager.sendTo(to, {
184
184
  type: "tool_res",
185
185
  id,
186
186
  from: this.config.nodeId,
@@ -188,6 +188,9 @@ export class ToolProxy {
188
188
  timestamp: Date.now(),
189
189
  payload,
190
190
  } as ToolProxyResponse);
191
+ if (!sent) {
192
+ this.logger.warn(`[clawmatrix] Tool response dropped: id=${id} to="${to}" (no route)`);
193
+ }
191
194
  }
192
195
 
193
196
  // ── Gateway tool invocation ────────────────────────────────────