clawmatrix 0.2.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.2.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",
package/src/index.ts CHANGED
@@ -363,15 +363,52 @@ const plugin = {
363
363
  }
364
364
  }
365
365
  }
366
- // Auto-detect: scan OpenClaw channels config for enabled channels with groups
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
- // Delay setup until service has started
392
- setTimeout(setupApproval, 1000);
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`)
@@ -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 || this.notifyTargets.length === 0) {
522
- this.log(`sendNotifications: skipped (channelApi=${!!this.channelApi} targets=${this.notifyTargets.length})`);
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
- const channelObj = this.channelApi[target.channel];
557
-
558
- // Convention: sendMessage{Channel} e.g. sendMessageTelegram
559
- const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
560
- const methodName = `sendMessage${capitalize(target.channel)}`;
561
- const sendFn = channelObj?.[methodName];
562
-
563
- if (typeof sendFn === "function") {
564
- // Direct channel API (built-in channels like telegram)
565
- const opts: Record<string, unknown> = {
566
- accountId: target.accountId,
567
- messageThreadId: target.threadId,
568
- };
569
- if (mode === "required") {
570
- opts.buttons = [
571
- [
572
- { text: "\u2705 Approve", callback_data: approveCmd },
573
- { text: "\u274c Deny", callback_data: denyCmd },
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
- await sendFn(target.to, message, opts);
578
- this.log(`sendNotifications: sent to ${target.channel}/${target.to} via channelApi`);
579
- } else if (this.gatewaySend) {
580
- // Fallback: gateway send method (works for all channels including plugins like feishu)
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
- } else {
592
- this.log(`sendNotifications: no channelApi or gatewaySend for "${target.channel}"`);
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");
@@ -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
- if (decision === "approve" && conn.isOpen) {
376
- conn.completeAuth();
377
- this.completePeerJoin(conn, caps);
378
- } else if (conn.isOpen) {
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
- conn.close(
401
+ activeConn.close(
381
402
  decision === "timeout" ? 4004 : 4005,
382
403
  decision === "timeout" ? "approval timeout" : "approval denied",
383
404
  );