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 +1 -1
- package/src/connection.ts +25 -1
- package/src/handoff.ts +4 -0
- package/src/index.ts +6 -3
- package/src/peer-manager.ts +14 -0
- package/src/router.ts +10 -1
- package/src/tool-proxy.ts +4 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
@@ -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 ────────────────────────────────────
|