clawmatrix 0.2.4 → 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.4",
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/compat.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { spawn as cpSpawn } from "node:child_process";
8
8
  import { readFile, writeFile } from "node:fs/promises";
9
+ import { createRequire } from "node:module";
9
10
 
10
11
  export interface SpawnResult {
11
12
  exitCode: number;
@@ -97,8 +98,8 @@ let ptyModule: {
97
98
  function loadPty() {
98
99
  if (ptyModule !== undefined) return ptyModule;
99
100
  try {
100
- // eslint-disable-next-line @typescript-eslint/no-require-imports
101
- ptyModule = require("node-pty");
101
+ const req = createRequire(import.meta.url);
102
+ ptyModule = req("node-pty");
102
103
  } catch {
103
104
  ptyModule = null;
104
105
  }
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/identity.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  * ECDH exchange with the peer's key.
27
27
  */
28
28
 
29
+ import { createPrivateKey } from "node:crypto";
29
30
  import fs from "node:fs";
30
31
  import path from "node:path";
31
32
  import {
@@ -82,7 +83,6 @@ export function loadOrCreateIdentity(stateDir: string): KeyPair {
82
83
 
83
84
  /** Reconstruct a KeyPair from serialized base64 strings. */
84
85
  function keyPairFromSerialized(publicKeyB64: string, privateKeyB64: string): KeyPair {
85
- const { createPrivateKey } = require("node:crypto");
86
86
  const publicKey = Buffer.from(publicKeyB64, "base64");
87
87
  const privateKey = Buffer.from(privateKeyB64, "base64");
88
88
 
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.",
@@ -28,6 +28,23 @@ import type { KeyPair } from "./crypto.ts";
28
28
  const RECONNECT_BASE = 1_000;
29
29
  const RECONNECT_MAX = 60_000;
30
30
 
31
+ /** Classify WebSocket close code into a human-readable reason. */
32
+ function classifyCloseReason(code: number, reason: string): string {
33
+ if (reason) return reason;
34
+ switch (code) {
35
+ case 1006: return "unreachable (node may be down)";
36
+ case 1000: return "normal close";
37
+ case 1001: return "peer going away";
38
+ case 1002: return "protocol error";
39
+ case 1003: return "unsupported data";
40
+ case 1008: return "policy violation";
41
+ case 1011: return "server error";
42
+ case 4001: return "auth failed";
43
+ case 4003: return "auth timeout";
44
+ default: return `close code ${code}`;
45
+ }
46
+ }
47
+
31
48
  /** Check if an IP is a loopback address (IPv4 127.x or IPv6 ::1). */
32
49
  function isLoopback(ip?: string): boolean {
33
50
  if (!ip) return false;
@@ -324,24 +341,27 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
324
341
  });
325
342
 
326
343
  let reconnectScheduled = false;
344
+ let lastError: string | undefined;
327
345
  const tryReconnect = () => {
328
346
  if (!reconnectScheduled) {
329
347
  reconnectScheduled = true;
330
- this.scheduleReconnect(peer);
348
+ this.scheduleReconnect(peer, lastError);
331
349
  }
332
350
  };
333
351
 
334
352
  ws.addEventListener("error", (ev) => {
335
- debug("peer", `connectToPeer(${peer.nodeId}): ws error: ${(ev as ErrorEvent).message ?? "unknown"}`);
353
+ lastError = (ev as ErrorEvent).message || undefined;
336
354
  tryReconnect();
337
355
  });
338
356
  ws.addEventListener("close", (ev) => {
339
- debug("peer", `connectToPeer(${peer.nodeId}): ws close code=${ev.code} reason=${ev.reason}`);
357
+ if (!lastError) {
358
+ lastError = classifyCloseReason(ev.code, ev.reason);
359
+ }
340
360
  tryReconnect();
341
361
  });
342
362
  }
343
363
 
344
- private scheduleReconnect(peer: PeerConfig) {
364
+ private scheduleReconnect(peer: PeerConfig, reason?: string) {
345
365
  if (this.stopped) {
346
366
  debug("peer", `scheduleReconnect(${peer.nodeId}): skipped (stopped)`);
347
367
  return;
@@ -351,7 +371,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
351
371
  const attempt = this.reconnectAttempts.get(peer.nodeId) ?? 0;
352
372
  const delay = Math.min(RECONNECT_BASE * 2 ** attempt, RECONNECT_MAX);
353
373
  this.reconnectAttempts.set(peer.nodeId, attempt + 1);
354
- debug("peer", `scheduleReconnect(${peer.nodeId}): attempt=${attempt} delay=${delay}ms`);
374
+ const tag = reason ? ` reason="${reason}"` : "";
375
+ debug("peer", `scheduleReconnect(${peer.nodeId}): attempt=${attempt} delay=${delay}ms${tag}`);
355
376
 
356
377
  const timer = setTimeout(() => {
357
378
  this.reconnectTimers.delete(peer.nodeId);
@@ -366,6 +387,14 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
366
387
  // Peer's persistent public key for TOFU identity binding
367
388
  const peerPublicKey = conn.remoteIdentityKey ?? undefined;
368
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
+
369
398
  // Peer approval check (inbound only — outbound peers are explicitly configured)
370
399
  // Skip approval for same-nodeId connections from localhost (local clients
371
400
  // like Mac desktop app / iOS simulator). An attacker would need to already
@@ -519,6 +548,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
519
548
 
520
549
  // ── Message handling ───────────────────────────────────────────
521
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
+
522
554
  // Validate from field: must be the direct peer or a known node (relayed)
523
555
  if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) {
524
556
  debug("peer", `dropped frame type=${frame.type} from=${frame.from}: unknown origin (via ${from.remoteNodeId})`);
@@ -670,6 +702,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
670
702
  changed = true;
671
703
  }
672
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;
673
708
  const existing = this.router.getRoute(peer.nodeId);
674
709
  if (!existing) changed = true;
675
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 ────────────────────────────────────