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 +1 -1
- package/src/compat.ts +3 -2
- package/src/connection.ts +25 -1
- package/src/handoff.ts +4 -0
- package/src/identity.ts +1 -1
- package/src/index.ts +6 -3
- package/src/peer-manager.ts +40 -5
- package/src/router.ts +10 -1
- package/src/tool-proxy.ts +4 -1
package/package.json
CHANGED
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
|
-
|
|
101
|
-
ptyModule =
|
|
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
|
-
|
|
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()
|
|
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.",
|
package/src/peer-manager.ts
CHANGED
|
@@ -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
|
-
|
|
353
|
+
lastError = (ev as ErrorEvent).message || undefined;
|
|
336
354
|
tryReconnect();
|
|
337
355
|
});
|
|
338
356
|
ws.addEventListener("close", (ev) => {
|
|
339
|
-
|
|
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
|
-
|
|
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 ────────────────────────────────────
|