clawmatrix 0.2.1 → 0.2.3
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 +3 -2
- package/src/index.ts +55 -6
- package/src/peer-approval.ts +143 -28
- package/src/peer-manager.ts +26 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
|
@@ -31,12 +31,13 @@
|
|
|
31
31
|
"release": "bunx bumpp"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"@agentclientprotocol/sdk": "^0.16.1",
|
|
34
35
|
"@automerge/automerge": "^3.2.4",
|
|
35
36
|
"@mariozechner/pi-coding-agent": ">=0.55.0",
|
|
36
37
|
"ignore": "^7.0.5",
|
|
38
|
+
"node-pty": "^1.0.0",
|
|
37
39
|
"picomatch": "^4.0.3",
|
|
38
40
|
"ws": "^8.19.0",
|
|
39
|
-
"node-pty": "^1.0.0",
|
|
40
41
|
"zod": "^4.3.6"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -363,15 +363,52 @@ const plugin = {
|
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
|
-
// Auto-detect:
|
|
366
|
+
// Auto-detect: prefer DM targets (owner) over group targets
|
|
367
367
|
if (targets.length === 0) {
|
|
368
368
|
const channelsConfig = (api.config as Record<string, unknown>).channels as
|
|
369
|
-
Record<string, { enabled?: boolean; groups?: Record<string, unknown> }> | undefined;
|
|
369
|
+
Record<string, { enabled?: boolean; allowFrom?: Array<string | number>; groups?: Record<string, unknown> }> | undefined;
|
|
370
|
+
|
|
371
|
+
// Parse commands.ownerAllowFrom for channel-prefixed owner IDs
|
|
372
|
+
// e.g. ["telegram:12345", "feishu:user_abc"] → { telegram: "12345", feishu: "user_abc" }
|
|
373
|
+
const commandsConfig = (api.config as Record<string, unknown>).commands as
|
|
374
|
+
{ ownerAllowFrom?: Array<string | number> } | undefined;
|
|
375
|
+
const ownerByChannel = new Map<string, string>();
|
|
376
|
+
if (commandsConfig?.ownerAllowFrom) {
|
|
377
|
+
for (const entry of commandsConfig.ownerAllowFrom) {
|
|
378
|
+
const str = String(entry).trim();
|
|
379
|
+
const colonIdx = str.indexOf(":");
|
|
380
|
+
if (colonIdx > 0) {
|
|
381
|
+
const ch = str.slice(0, colonIdx).toLowerCase();
|
|
382
|
+
const id = str.slice(colonIdx + 1).trim();
|
|
383
|
+
if (id && !ownerByChannel.has(ch)) {
|
|
384
|
+
ownerByChannel.set(ch, id);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
370
390
|
if (channelsConfig) {
|
|
371
391
|
for (const [channelId, chConf] of Object.entries(channelsConfig)) {
|
|
372
|
-
if (!chConf || chConf.enabled === false) continue;
|
|
392
|
+
if (!chConf || typeof chConf !== "object" || chConf.enabled === false) continue;
|
|
393
|
+
|
|
394
|
+
// Priority 1: owner from commands.ownerAllowFrom → DM
|
|
395
|
+
const ownerDm = ownerByChannel.get(channelId);
|
|
396
|
+
if (ownerDm) {
|
|
397
|
+
targets.push({ channel: channelId, to: ownerDm });
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Priority 2: channel allowFrom (DM allowlist) → first entry as DM
|
|
402
|
+
if (Array.isArray(chConf.allowFrom) && chConf.allowFrom.length > 0) {
|
|
403
|
+
const firstUser = String(chConf.allowFrom[0]).trim();
|
|
404
|
+
if (firstUser && firstUser !== "*") {
|
|
405
|
+
targets.push({ channel: channelId, to: firstUser });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Priority 3: groups (fallback)
|
|
373
411
|
if (chConf.groups && typeof chConf.groups === "object") {
|
|
374
|
-
// Use the first configured group as notification target
|
|
375
412
|
const firstGroupId = Object.keys(chConf.groups)[0];
|
|
376
413
|
if (firstGroupId) {
|
|
377
414
|
targets.push({ channel: channelId, to: firstGroupId });
|
|
@@ -388,8 +425,20 @@ const plugin = {
|
|
|
388
425
|
}
|
|
389
426
|
};
|
|
390
427
|
|
|
391
|
-
//
|
|
392
|
-
|
|
428
|
+
// Retry until cluster runtime is initialized (service start is async)
|
|
429
|
+
const retrySetup = (attempt = 0) => {
|
|
430
|
+
try {
|
|
431
|
+
getClusterRuntime();
|
|
432
|
+
setupApproval();
|
|
433
|
+
} catch {
|
|
434
|
+
if (attempt < 30) {
|
|
435
|
+
setTimeout(() => retrySetup(attempt + 1), 1000);
|
|
436
|
+
} else {
|
|
437
|
+
debug("approval", "setupApproval gave up after 30 attempts — cluster runtime never initialized");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
setTimeout(() => retrySetup(), 1000);
|
|
393
442
|
}
|
|
394
443
|
|
|
395
444
|
// Gateway methods (queried by CLI via `openclaw gateway call`)
|
package/src/peer-approval.ts
CHANGED
|
@@ -101,6 +101,8 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
101
101
|
private loaded = false;
|
|
102
102
|
/** IP → list of deny timestamps (for approval noise suppression). */
|
|
103
103
|
private ipDenyHistory = new Map<string, number[]>();
|
|
104
|
+
/** Active sentinel polling timers (for cleanup on destroy). */
|
|
105
|
+
private sentinelTimers = new Set<ReturnType<typeof setInterval>>();
|
|
104
106
|
|
|
105
107
|
constructor(config: PeerApprovalConfig, stateDir: string) {
|
|
106
108
|
super();
|
|
@@ -190,6 +192,15 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
190
192
|
// Always allow pre-approved nodeIds from config
|
|
191
193
|
if (this.config.allowList.includes(nodeId)) return "allow";
|
|
192
194
|
|
|
195
|
+
// Auto-allow sentinel companions: if "X:sentinel" connects and "X" is approved, allow it
|
|
196
|
+
const sentinelSuffix = ":sentinel";
|
|
197
|
+
if (nodeId.endsWith(sentinelSuffix)) {
|
|
198
|
+
const baseNodeId = nodeId.slice(0, -sentinelSuffix.length);
|
|
199
|
+
if (this.data.approved[baseNodeId] || this.config.allowList.includes(baseNodeId)) {
|
|
200
|
+
return "allow";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
193
204
|
// Already approved (persisted)
|
|
194
205
|
const approved = this.data.approved[nodeId];
|
|
195
206
|
if (approved) {
|
|
@@ -236,6 +247,16 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
236
247
|
publicKey?: string,
|
|
237
248
|
ip?: string,
|
|
238
249
|
): Promise<"approve" | "deny" | "timeout"> {
|
|
250
|
+
// Sentinel companion: piggyback on the base nodeId's approval instead of
|
|
251
|
+
// creating a separate request. If the base node's approval is already pending,
|
|
252
|
+
// wait for that decision. If the sentinel arrives first, wait for the base
|
|
253
|
+
// node's approval to appear (up to the configured timeout).
|
|
254
|
+
const sentinelSuffix = ":sentinel";
|
|
255
|
+
if (nodeId.endsWith(sentinelSuffix)) {
|
|
256
|
+
const baseNodeId = nodeId.slice(0, -sentinelSuffix.length);
|
|
257
|
+
return this.waitForBaseApproval(baseNodeId, nodeId, capabilities, publicKey);
|
|
258
|
+
}
|
|
259
|
+
|
|
239
260
|
const approvalId = crypto.randomUUID();
|
|
240
261
|
this.log(`requestApproval: nodeId=${nodeId} mode=${this.config.mode} approvalId=${approvalId}`);
|
|
241
262
|
|
|
@@ -481,6 +502,79 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
481
502
|
});
|
|
482
503
|
}
|
|
483
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Wait for the base nodeId's approval decision (for sentinel companions).
|
|
507
|
+
* If the base already has a pending approval, piggyback on it.
|
|
508
|
+
* If not yet pending, poll briefly until it appears or timeout.
|
|
509
|
+
*/
|
|
510
|
+
private waitForBaseApproval(
|
|
511
|
+
baseNodeId: string,
|
|
512
|
+
sentinelNodeId: string,
|
|
513
|
+
capabilities: NodeCapabilities,
|
|
514
|
+
publicKey?: string,
|
|
515
|
+
): Promise<"approve" | "deny" | "timeout"> {
|
|
516
|
+
const autoApprove = (decision: "approve" | "deny" | "timeout") => {
|
|
517
|
+
if (decision === "approve") {
|
|
518
|
+
this.addApproved(sentinelNodeId, capabilities.deviceInfo, publicKey, {
|
|
519
|
+
source: `auto:sentinel-of:${baseNodeId}`, at: Date.now(),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return decision;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// Try to find an existing pending approval for the base node
|
|
526
|
+
const tryPiggyback = (): Promise<"approve" | "deny" | "timeout"> | null => {
|
|
527
|
+
for (const pending of this.pending.values()) {
|
|
528
|
+
if (pending.nodeId === baseNodeId) {
|
|
529
|
+
this.log(`sentinel ${sentinelNodeId} piggybacking on pending approval for ${baseNodeId}`);
|
|
530
|
+
return new Promise<"approve" | "deny" | "timeout">((resolve) => {
|
|
531
|
+
const origResolve = pending.resolve;
|
|
532
|
+
pending.resolve = (decision) => {
|
|
533
|
+
origResolve(decision);
|
|
534
|
+
resolve(autoApprove(decision));
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const existing = tryPiggyback();
|
|
543
|
+
if (existing) return existing;
|
|
544
|
+
|
|
545
|
+
// Base node's approval hasn't been created yet (sentinel arrived first).
|
|
546
|
+
// Poll briefly — the base node should connect within a few seconds.
|
|
547
|
+
this.log(`sentinel ${sentinelNodeId} waiting for base node ${baseNodeId} approval to appear`);
|
|
548
|
+
return new Promise<"approve" | "deny" | "timeout">((resolve) => {
|
|
549
|
+
let attempts = 0;
|
|
550
|
+
const maxAttempts = 30; // 30 × 1s = 30s max wait
|
|
551
|
+
const cleanup = () => { clearInterval(timer); this.sentinelTimers.delete(timer); };
|
|
552
|
+
const timer = setInterval(() => {
|
|
553
|
+
attempts++;
|
|
554
|
+
|
|
555
|
+
// Check if base got approved while we were waiting
|
|
556
|
+
if (this.data.approved[baseNodeId]) {
|
|
557
|
+
cleanup();
|
|
558
|
+
resolve(autoApprove("approve"));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const result = tryPiggyback();
|
|
563
|
+
if (result) {
|
|
564
|
+
cleanup();
|
|
565
|
+
result.then(resolve);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (attempts >= maxAttempts) {
|
|
570
|
+
cleanup();
|
|
571
|
+
resolve("timeout");
|
|
572
|
+
}
|
|
573
|
+
}, 1_000);
|
|
574
|
+
this.sentinelTimers.add(timer);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
484
578
|
private addApproved(nodeId: string, deviceInfo?: DeviceInfo, publicKey?: string, resolvedBy?: ApprovalResolvedBy) {
|
|
485
579
|
this.data.approved[nodeId] = {
|
|
486
580
|
nodeId,
|
|
@@ -518,8 +612,12 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
518
612
|
mode: "notify" | "required",
|
|
519
613
|
ip?: string,
|
|
520
614
|
) {
|
|
521
|
-
if (!this.channelApi
|
|
522
|
-
this.log(`sendNotifications: skipped (channelApi
|
|
615
|
+
if (!this.channelApi && !this.gatewaySend) {
|
|
616
|
+
this.log(`sendNotifications: skipped (no channelApi or gatewaySend)`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (this.notifyTargets.length === 0) {
|
|
620
|
+
this.log(`sendNotifications: skipped (no targets)`);
|
|
523
621
|
return;
|
|
524
622
|
}
|
|
525
623
|
this.log(`sendNotifications: sending to ${this.notifyTargets.length} targets`);
|
|
@@ -553,31 +651,40 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
553
651
|
|
|
554
652
|
for (const target of this.notifyTargets) {
|
|
555
653
|
try {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
654
|
+
let sent = false;
|
|
655
|
+
|
|
656
|
+
// Try direct channel API first (built-in channels like telegram)
|
|
657
|
+
if (this.channelApi) {
|
|
658
|
+
const channelObj = this.channelApi[target.channel];
|
|
659
|
+
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
660
|
+
const methodName = `sendMessage${capitalize(target.channel)}`;
|
|
661
|
+
const sendFn = channelObj?.[methodName];
|
|
662
|
+
|
|
663
|
+
if (typeof sendFn === "function") {
|
|
664
|
+
try {
|
|
665
|
+
const opts: Record<string, unknown> = {
|
|
666
|
+
accountId: target.accountId,
|
|
667
|
+
messageThreadId: target.threadId,
|
|
668
|
+
};
|
|
669
|
+
if (mode === "required") {
|
|
670
|
+
opts.buttons = [
|
|
671
|
+
[
|
|
672
|
+
{ text: "\u2705 Approve", callback_data: approveCmd },
|
|
673
|
+
{ text: "\u274c Deny", callback_data: denyCmd },
|
|
674
|
+
],
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
await sendFn(target.to, message, opts);
|
|
678
|
+
this.log(`sendNotifications: sent to ${target.channel}/${target.to} via channelApi`);
|
|
679
|
+
sent = true;
|
|
680
|
+
} catch (apiErr) {
|
|
681
|
+
this.log(`sendNotifications: channelApi failed for ${target.channel}/${target.to}: ${apiErr}, trying gatewaySend`);
|
|
682
|
+
}
|
|
576
683
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Fallback: gateway send (works for all channels, more reliable)
|
|
687
|
+
if (!sent && this.gatewaySend) {
|
|
581
688
|
await this.gatewaySend({
|
|
582
689
|
to: target.to,
|
|
583
690
|
message: mode === "required"
|
|
@@ -588,8 +695,11 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
588
695
|
threadId: target.threadId != null ? String(target.threadId) : undefined,
|
|
589
696
|
});
|
|
590
697
|
this.log(`sendNotifications: sent to ${target.channel}/${target.to} via gatewaySend`);
|
|
591
|
-
|
|
592
|
-
|
|
698
|
+
sent = true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!sent) {
|
|
702
|
+
this.log(`sendNotifications: no send method available for "${target.channel}"`);
|
|
593
703
|
}
|
|
594
704
|
} catch (err) {
|
|
595
705
|
this.log(`sendNotifications: failed for ${target.channel}/${target.to}: ${err}`);
|
|
@@ -619,6 +729,11 @@ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
|
619
729
|
}
|
|
620
730
|
|
|
621
731
|
destroy() {
|
|
732
|
+
// Clean up sentinel polling timers
|
|
733
|
+
for (const timer of this.sentinelTimers) {
|
|
734
|
+
clearInterval(timer);
|
|
735
|
+
}
|
|
736
|
+
this.sentinelTimers.clear();
|
|
622
737
|
// Reject all pending approvals
|
|
623
738
|
for (const pending of this.pending.values()) {
|
|
624
739
|
pending.resolve("deny");
|
package/src/peer-manager.ts
CHANGED
|
@@ -59,6 +59,8 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
59
59
|
/** Map from ws WebSocket to remote IP (for audit logging on close). */
|
|
60
60
|
private inboundIps = new Map<WsWebSocket, string>();
|
|
61
61
|
private gossipDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
/** Latest connection per nodeId awaiting peer approval (updated on reconnect). */
|
|
63
|
+
private pendingApprovalConns = new Map<string, { conn: Connection; caps: NodeCapabilities }>();
|
|
62
64
|
/** Persistent X25519 identity key pair (TOFU). See identity.ts for security model. */
|
|
63
65
|
private identityKeyPair: KeyPair;
|
|
64
66
|
private e2eeOptions: ConnectionE2eeOptions;
|
|
@@ -360,8 +362,23 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
360
362
|
return;
|
|
361
363
|
}
|
|
362
364
|
if (check === "pending") {
|
|
365
|
+
// If already waiting for approval for this nodeId (remote side
|
|
366
|
+
// reconnected after auth timeout), just update the connection ref
|
|
367
|
+
// so the existing .then() handler acts on the latest connection.
|
|
368
|
+
// Do NOT call requestApproval again — that would register duplicate
|
|
369
|
+
// .then() handlers that all call completePeerJoin.
|
|
370
|
+
if (this.pendingApprovalConns.has(nodeId)) {
|
|
371
|
+
debug("approval", `reusing pending approval for ${nodeId}, updating conn ref`);
|
|
372
|
+
this.pendingApprovalConns.set(nodeId, { conn, caps });
|
|
373
|
+
if (this.config.peerApproval?.mode === "required") {
|
|
374
|
+
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
363
379
|
// In notify mode, requestApproval auto-approves and sends notification.
|
|
364
380
|
// In required mode, it waits for explicit approval.
|
|
381
|
+
this.pendingApprovalConns.set(nodeId, { conn, caps });
|
|
365
382
|
this.approvalManager.requestApproval(
|
|
366
383
|
nodeId,
|
|
367
384
|
caps,
|
|
@@ -372,12 +389,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
372
389
|
peerPublicKey,
|
|
373
390
|
ip,
|
|
374
391
|
).then((decision) => {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
392
|
+
const latest = this.pendingApprovalConns.get(nodeId);
|
|
393
|
+
this.pendingApprovalConns.delete(nodeId);
|
|
394
|
+
const activeConn = latest?.conn ?? conn;
|
|
395
|
+
const activeCaps = latest?.caps ?? caps;
|
|
396
|
+
if (decision === "approve" && activeConn.isOpen) {
|
|
397
|
+
activeConn.completeAuth();
|
|
398
|
+
this.completePeerJoin(activeConn, activeCaps);
|
|
399
|
+
} else if (activeConn.isOpen) {
|
|
379
400
|
if (ip) this.approvalManager.recordIpDeny(ip);
|
|
380
|
-
|
|
401
|
+
activeConn.close(
|
|
381
402
|
decision === "timeout" ? 4004 : 4005,
|
|
382
403
|
decision === "timeout" ? "approval timeout" : "approval denied",
|
|
383
404
|
);
|