clawmatrix 0.1.23 → 0.2.0
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/README.md +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2073 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +288 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +131 -86
- package/src/identity.ts +95 -0
- package/src/index.ts +467 -52
- package/src/knowledge-sync.ts +776 -207
- package/src/model-proxy.ts +144 -39
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +261 -32
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +475 -3
- package/src/web.ts +2 -2
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { PeerApprovalConfig } from "./config.ts";
|
|
5
|
+
import { debug } from "./debug.ts";
|
|
6
|
+
import type {
|
|
7
|
+
AnyClusterFrame,
|
|
8
|
+
DeviceInfo,
|
|
9
|
+
NodeCapabilities,
|
|
10
|
+
PeerApprovalNotify,
|
|
11
|
+
PeerApprovalRequest,
|
|
12
|
+
PeerApprovalResponse,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
const RATE_LIMIT_WINDOW = 600_000; // 10 min cooldown after deny
|
|
16
|
+
const IP_DENY_THRESHOLD = 3; // auto-block IP after N denied approvals
|
|
17
|
+
const IP_DENY_WINDOW = 1_800_000; // 30 min IP block window
|
|
18
|
+
|
|
19
|
+
/** Who resolved the approval and how. */
|
|
20
|
+
export interface ApprovalResolvedBy {
|
|
21
|
+
/** Source: which node/channel resolved this. e.g. "home-macmini", "telegram:95123182" */
|
|
22
|
+
source: string;
|
|
23
|
+
/** Timestamp of the resolution. */
|
|
24
|
+
at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApprovedPeerRecord {
|
|
28
|
+
nodeId: string;
|
|
29
|
+
approvedAt: number;
|
|
30
|
+
deviceInfo?: DeviceInfo;
|
|
31
|
+
/**
|
|
32
|
+
* Persistent public key (base64) pinned at approval time (TOFU).
|
|
33
|
+
*/
|
|
34
|
+
publicKey?: string;
|
|
35
|
+
/** Who approved this peer and when. */
|
|
36
|
+
resolvedBy?: ApprovalResolvedBy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DeniedPeerRecord {
|
|
40
|
+
nodeId: string;
|
|
41
|
+
deniedAt: number;
|
|
42
|
+
deviceInfo?: DeviceInfo;
|
|
43
|
+
/** Who denied this peer and when. */
|
|
44
|
+
resolvedBy?: ApprovalResolvedBy;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PersistentData {
|
|
48
|
+
approved: Record<string, ApprovedPeerRecord>;
|
|
49
|
+
denied: Record<string, DeniedPeerRecord | number>; // number for legacy compat
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PendingApproval {
|
|
53
|
+
approvalId: string;
|
|
54
|
+
nodeId: string;
|
|
55
|
+
deviceInfo?: DeviceInfo;
|
|
56
|
+
capabilities: NodeCapabilities;
|
|
57
|
+
/** Peer's persistent public key (base64), to be pinned on approval. */
|
|
58
|
+
publicKey?: string;
|
|
59
|
+
createdAt: number;
|
|
60
|
+
resolve: (decision: "approve" | "deny") => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PeerApprovalEvents {
|
|
64
|
+
/** Fired when a new peer needs notification/approval. */
|
|
65
|
+
notify: [approval: PendingApproval];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Channel target for sending notifications. */
|
|
69
|
+
export interface NotifyTarget {
|
|
70
|
+
channel: string;
|
|
71
|
+
to: string;
|
|
72
|
+
accountId?: string;
|
|
73
|
+
threadId?: string | number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Minimal interface for sending messages via OpenClaw channel API. */
|
|
77
|
+
export interface ChannelApi {
|
|
78
|
+
[channel: string]: {
|
|
79
|
+
[method: string]: (to: string, text: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
|
80
|
+
} | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Gateway send function for channels not available in the runtime channel API (e.g. plugin channels). */
|
|
84
|
+
export type GatewaySendFn = (params: {
|
|
85
|
+
to: string;
|
|
86
|
+
message: string;
|
|
87
|
+
channel: string;
|
|
88
|
+
accountId?: string;
|
|
89
|
+
threadId?: string;
|
|
90
|
+
}) => Promise<unknown>;
|
|
91
|
+
|
|
92
|
+
export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
|
|
93
|
+
private config: PeerApprovalConfig;
|
|
94
|
+
private stateDir: string;
|
|
95
|
+
private data: PersistentData = { approved: {}, denied: {} };
|
|
96
|
+
private pending = new Map<string, PendingApproval>();
|
|
97
|
+
private channelApi: ChannelApi | null = null;
|
|
98
|
+
private gatewaySend: GatewaySendFn | null = null;
|
|
99
|
+
private broadcastFn: ((frame: AnyClusterFrame) => void) | null = null;
|
|
100
|
+
private notifyTargets: NotifyTarget[] = [];
|
|
101
|
+
private loaded = false;
|
|
102
|
+
/** IP → list of deny timestamps (for approval noise suppression). */
|
|
103
|
+
private ipDenyHistory = new Map<string, number[]>();
|
|
104
|
+
|
|
105
|
+
constructor(config: PeerApprovalConfig, stateDir: string) {
|
|
106
|
+
super();
|
|
107
|
+
this.config = config;
|
|
108
|
+
this.stateDir = stateDir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private log(msg: string) {
|
|
112
|
+
debug("approval", msg);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Set the OpenClaw channel API for sending notifications. */
|
|
116
|
+
setChannelApi(channelApi: ChannelApi | null) {
|
|
117
|
+
this.channelApi = channelApi;
|
|
118
|
+
this.log(`setChannelApi: ${channelApi ? Object.keys(channelApi).join(",") : "null"}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Set a gateway send function for plugin channels not in the runtime API. */
|
|
122
|
+
setGatewaySend(fn: GatewaySendFn | null) {
|
|
123
|
+
this.gatewaySend = fn;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Set the broadcast function for sending frames to mesh peers. */
|
|
127
|
+
setBroadcastFn(fn: ((frame: AnyClusterFrame) => void) | null) {
|
|
128
|
+
this.broadcastFn = fn;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Set notification targets (from OpenClaw approvals config). */
|
|
132
|
+
setNotifyTargets(targets: NotifyTarget[]) {
|
|
133
|
+
this.notifyTargets = targets;
|
|
134
|
+
this.log(`setNotifyTargets: ${targets.map(t => `${t.channel}/${t.to}`).join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Persistence ─────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async load() {
|
|
140
|
+
if (this.loaded) return;
|
|
141
|
+
try {
|
|
142
|
+
const filePath = path.join(this.stateDir, this.config.persistPath);
|
|
143
|
+
if (fs.existsSync(filePath)) {
|
|
144
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
145
|
+
const parsed = JSON.parse(raw);
|
|
146
|
+
this.data = {
|
|
147
|
+
approved: parsed.approved ?? {},
|
|
148
|
+
denied: parsed.denied ?? {},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Start fresh
|
|
153
|
+
}
|
|
154
|
+
this.loaded = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private save() {
|
|
158
|
+
try {
|
|
159
|
+
const filePath = path.join(this.stateDir, this.config.persistPath);
|
|
160
|
+
// Ensure directory exists
|
|
161
|
+
const dir = path.dirname(filePath);
|
|
162
|
+
if (!fs.existsSync(dir)) {
|
|
163
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
164
|
+
}
|
|
165
|
+
fs.writeFileSync(filePath, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
|
166
|
+
} catch {
|
|
167
|
+
// Best-effort
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Core logic ──────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if a peer should be allowed to join.
|
|
175
|
+
* Returns: "allow" (immediate), "pending" (wait for approval), or "deny" (rate-limited).
|
|
176
|
+
*
|
|
177
|
+
* @param publicKey - The peer's persistent public key (base64) from the E2EE
|
|
178
|
+
* handshake. Used for TOFU identity verification: if the peer was previously
|
|
179
|
+
* approved with a different public key, it is treated as a new device and
|
|
180
|
+
* must be re-approved. This prevents nodeId impersonation — an attacker who
|
|
181
|
+
* obtains the shared secret cannot forge the private key backing this public key.
|
|
182
|
+
*/
|
|
183
|
+
checkPeer(
|
|
184
|
+
nodeId: string,
|
|
185
|
+
capabilities: NodeCapabilities,
|
|
186
|
+
publicKey?: string,
|
|
187
|
+
): "allow" | "pending" | "deny" {
|
|
188
|
+
if (!this.config.enabled) return "allow";
|
|
189
|
+
|
|
190
|
+
// Always allow pre-approved nodeIds from config
|
|
191
|
+
if (this.config.allowList.includes(nodeId)) return "allow";
|
|
192
|
+
|
|
193
|
+
// Already approved (persisted)
|
|
194
|
+
const approved = this.data.approved[nodeId];
|
|
195
|
+
if (approved) {
|
|
196
|
+
// TOFU public key verification: if the approved record has a pinned key
|
|
197
|
+
// and the connecting peer presents a different key, treat as a new device.
|
|
198
|
+
// This is the core defense against nodeId impersonation.
|
|
199
|
+
if (approved.publicKey && publicKey && approved.publicKey !== publicKey) {
|
|
200
|
+
return "pending";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Legacy: deviceInfo hostname change detection (weaker signal, kept
|
|
204
|
+
// for backwards compatibility with records that lack a public key)
|
|
205
|
+
if (!approved.publicKey) {
|
|
206
|
+
const newHostname = capabilities.deviceInfo?.hostname;
|
|
207
|
+
const oldHostname = approved.deviceInfo?.hostname;
|
|
208
|
+
if (oldHostname && newHostname && oldHostname !== newHostname) {
|
|
209
|
+
return "pending";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return "allow";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Recently denied — rate limit
|
|
217
|
+
const denied = this.data.denied[nodeId];
|
|
218
|
+
const deniedAt = typeof denied === "number" ? denied : denied?.deniedAt;
|
|
219
|
+
if (deniedAt && Date.now() - deniedAt < RATE_LIMIT_WINDOW) {
|
|
220
|
+
return "deny";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return "pending";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a pending approval and return a promise that resolves with the decision.
|
|
228
|
+
* In "notify" mode, the promise resolves immediately with "approve".
|
|
229
|
+
*
|
|
230
|
+
* @param publicKey - The peer's persistent public key (base64), pinned on approval.
|
|
231
|
+
*/
|
|
232
|
+
async requestApproval(
|
|
233
|
+
nodeId: string,
|
|
234
|
+
capabilities: NodeCapabilities,
|
|
235
|
+
broadcastFn: (frame: AnyClusterFrame) => void,
|
|
236
|
+
publicKey?: string,
|
|
237
|
+
ip?: string,
|
|
238
|
+
): Promise<"approve" | "deny" | "timeout"> {
|
|
239
|
+
const approvalId = crypto.randomUUID();
|
|
240
|
+
this.log(`requestApproval: nodeId=${nodeId} mode=${this.config.mode} approvalId=${approvalId}`);
|
|
241
|
+
|
|
242
|
+
if (this.config.mode === "notify") {
|
|
243
|
+
// Auto-approve, but send notifications
|
|
244
|
+
this.addApproved(nodeId, capabilities.deviceInfo, publicKey, { source: "auto:notify-mode", at: Date.now() });
|
|
245
|
+
this.sendNotifications(approvalId, nodeId, capabilities.deviceInfo, "notify", ip);
|
|
246
|
+
this.broadcastNotify(broadcastFn, approvalId, nodeId, capabilities.deviceInfo, "notify", ip);
|
|
247
|
+
return "approve";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Required mode: wait for explicit approval
|
|
251
|
+
return new Promise<"approve" | "deny" | "timeout">((resolve) => {
|
|
252
|
+
const pending: PendingApproval = {
|
|
253
|
+
approvalId,
|
|
254
|
+
nodeId,
|
|
255
|
+
deviceInfo: capabilities.deviceInfo,
|
|
256
|
+
capabilities,
|
|
257
|
+
publicKey,
|
|
258
|
+
createdAt: Date.now(),
|
|
259
|
+
resolve: (decision) => {
|
|
260
|
+
this.pending.delete(approvalId);
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
resolve(decision);
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
this.pending.set(approvalId, pending);
|
|
267
|
+
|
|
268
|
+
// Timeout
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
if (this.pending.has(approvalId)) {
|
|
271
|
+
this.pending.delete(approvalId);
|
|
272
|
+
resolve("timeout");
|
|
273
|
+
}
|
|
274
|
+
}, this.config.timeout);
|
|
275
|
+
|
|
276
|
+
// Send notifications to IM channels
|
|
277
|
+
this.sendNotifications(approvalId, nodeId, capabilities.deviceInfo, "required", ip);
|
|
278
|
+
this.broadcastNotify(broadcastFn, approvalId, nodeId, capabilities.deviceInfo, "required", ip);
|
|
279
|
+
|
|
280
|
+
// Broadcast approval request to mesh (for iOS etc.)
|
|
281
|
+
broadcastFn({
|
|
282
|
+
type: "peer_approval_req",
|
|
283
|
+
id: approvalId,
|
|
284
|
+
from: "", // will be set by PeerManager
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
payload: {
|
|
287
|
+
approvalId,
|
|
288
|
+
nodeId,
|
|
289
|
+
deviceInfo: capabilities.deviceInfo,
|
|
290
|
+
ip,
|
|
291
|
+
},
|
|
292
|
+
} as PeerApprovalRequest);
|
|
293
|
+
|
|
294
|
+
this.emit("notify", pending);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Handle an approval response from a connected peer or gateway method. */
|
|
299
|
+
handleResponse(approvalId: string, decision: "approve" | "deny", resolvedBy?: ApprovalResolvedBy) {
|
|
300
|
+
const pending = this.pending.get(approvalId);
|
|
301
|
+
if (!pending) return false;
|
|
302
|
+
|
|
303
|
+
const resolved = resolvedBy ?? { source: "unknown", at: Date.now() };
|
|
304
|
+
this.log(`handleResponse: ${decision} for ${pending.nodeId} by ${resolved.source}`);
|
|
305
|
+
|
|
306
|
+
if (decision === "approve") {
|
|
307
|
+
this.addApproved(pending.nodeId, pending.deviceInfo, pending.publicKey, resolved);
|
|
308
|
+
} else {
|
|
309
|
+
this.data.denied[pending.nodeId] = {
|
|
310
|
+
nodeId: pending.nodeId,
|
|
311
|
+
deniedAt: Date.now(),
|
|
312
|
+
deviceInfo: pending.deviceInfo,
|
|
313
|
+
resolvedBy: resolved,
|
|
314
|
+
};
|
|
315
|
+
this.save();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Send resolution notification to all channels + mesh
|
|
319
|
+
this.sendResolutionNotification(approvalId, pending.nodeId, pending.deviceInfo, decision, resolved);
|
|
320
|
+
|
|
321
|
+
pending.resolve(decision);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Handle a peer_approval_res frame from mesh. */
|
|
326
|
+
handleApprovalFrame(frame: PeerApprovalResponse) {
|
|
327
|
+
const resolvedBy: ApprovalResolvedBy = {
|
|
328
|
+
source: frame.from || "mesh",
|
|
329
|
+
at: Date.now(),
|
|
330
|
+
};
|
|
331
|
+
return this.handleResponse(frame.payload.approvalId, frame.payload.decision, resolvedBy);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Revoke an approved peer. */
|
|
335
|
+
revoke(nodeId: string, resolvedBy?: ApprovalResolvedBy): boolean {
|
|
336
|
+
const existing = this.data.approved[nodeId];
|
|
337
|
+
if (!existing) return false;
|
|
338
|
+
delete this.data.approved[nodeId];
|
|
339
|
+
this.data.denied[nodeId] = {
|
|
340
|
+
nodeId,
|
|
341
|
+
deniedAt: Date.now(),
|
|
342
|
+
deviceInfo: existing.deviceInfo,
|
|
343
|
+
resolvedBy: resolvedBy ?? { source: "unknown", at: Date.now() },
|
|
344
|
+
};
|
|
345
|
+
this.save();
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Get all approved peers. */
|
|
350
|
+
getApprovedPeers(): ApprovedPeerRecord[] {
|
|
351
|
+
return Object.values(this.data.approved);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Get all pending approvals. */
|
|
355
|
+
getPendingApprovals(): PendingApproval[] {
|
|
356
|
+
return [...this.pending.values()];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Get denied peer records (excluding legacy number-only entries). */
|
|
360
|
+
getDeniedPeers(): DeniedPeerRecord[] {
|
|
361
|
+
return Object.values(this.data.denied)
|
|
362
|
+
.filter((v): v is DeniedPeerRecord => typeof v === "object" && v !== null)
|
|
363
|
+
.sort((a, b) => b.deniedAt - a.deniedAt);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Forward a peer approval notification received from mesh to local IM channels.
|
|
368
|
+
* Called on nodes that are NOT the originator of the approval request.
|
|
369
|
+
*/
|
|
370
|
+
async forwardApprovalToIM(
|
|
371
|
+
approvalId: string,
|
|
372
|
+
nodeId: string,
|
|
373
|
+
deviceInfo?: DeviceInfo,
|
|
374
|
+
ip?: string,
|
|
375
|
+
) {
|
|
376
|
+
this.log(`forwardApprovalToIM: approvalId=${approvalId} nodeId=${nodeId}`);
|
|
377
|
+
await this.sendNotifications(approvalId, nodeId, deviceInfo, "required", ip);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Send resolution notification to all IM channels and broadcast to mesh.
|
|
382
|
+
* Called after a peer approval is resolved (approved or denied).
|
|
383
|
+
*/
|
|
384
|
+
private sendResolutionNotification(
|
|
385
|
+
approvalId: string,
|
|
386
|
+
nodeId: string,
|
|
387
|
+
deviceInfo: DeviceInfo | undefined,
|
|
388
|
+
decision: "approve" | "deny",
|
|
389
|
+
resolvedBy: ApprovalResolvedBy,
|
|
390
|
+
) {
|
|
391
|
+
// Send to local IM channels
|
|
392
|
+
this.sendResolutionToIM(approvalId, nodeId, deviceInfo, decision, resolvedBy);
|
|
393
|
+
|
|
394
|
+
// Broadcast to mesh so other nodes can notify their local channels
|
|
395
|
+
if (this.broadcastFn) {
|
|
396
|
+
this.broadcastFn({
|
|
397
|
+
type: "peer_approval_notify",
|
|
398
|
+
from: "",
|
|
399
|
+
timestamp: Date.now(),
|
|
400
|
+
payload: {
|
|
401
|
+
approvalId,
|
|
402
|
+
nodeId,
|
|
403
|
+
deviceInfo,
|
|
404
|
+
mode: "required",
|
|
405
|
+
resolution: { decision, resolvedBy: resolvedBy.source },
|
|
406
|
+
},
|
|
407
|
+
} as PeerApprovalNotify);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Send resolution result to local IM channels.
|
|
413
|
+
*/
|
|
414
|
+
private async sendResolutionToIM(
|
|
415
|
+
approvalId: string,
|
|
416
|
+
nodeId: string,
|
|
417
|
+
deviceInfo: DeviceInfo | undefined,
|
|
418
|
+
decision: "approve" | "deny",
|
|
419
|
+
resolvedBy: ApprovalResolvedBy,
|
|
420
|
+
) {
|
|
421
|
+
if (this.notifyTargets.length === 0) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!this.channelApi && !this.gatewaySend) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const deviceLabel = `${deviceInfo?.hostname ?? "unknown"} (${deviceInfo?.os ?? "?"})`;
|
|
429
|
+
const emoji = decision === "approve" ? "\u2705" : "\u274c";
|
|
430
|
+
const verb = decision === "approve" ? "Approved" : "Denied";
|
|
431
|
+
const message = [
|
|
432
|
+
`${emoji} Peer ${verb}`,
|
|
433
|
+
`Node: ${nodeId}`,
|
|
434
|
+
`Device: ${deviceLabel}`,
|
|
435
|
+
`By: ${resolvedBy.source}`,
|
|
436
|
+
].join("\n");
|
|
437
|
+
|
|
438
|
+
this.log(`sendResolutionToIM: ${decision} for ${nodeId} by ${resolvedBy.source}`);
|
|
439
|
+
|
|
440
|
+
for (const target of this.notifyTargets) {
|
|
441
|
+
try {
|
|
442
|
+
const channelObj = this.channelApi?.[target.channel];
|
|
443
|
+
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
444
|
+
const methodName = `sendMessage${capitalize(target.channel)}`;
|
|
445
|
+
const sendFn = channelObj?.[methodName];
|
|
446
|
+
|
|
447
|
+
if (typeof sendFn === "function") {
|
|
448
|
+
await sendFn(target.to, message, {
|
|
449
|
+
accountId: target.accountId,
|
|
450
|
+
messageThreadId: target.threadId,
|
|
451
|
+
});
|
|
452
|
+
} else if (this.gatewaySend) {
|
|
453
|
+
await this.gatewaySend({
|
|
454
|
+
to: target.to,
|
|
455
|
+
message,
|
|
456
|
+
channel: target.channel,
|
|
457
|
+
accountId: target.accountId,
|
|
458
|
+
threadId: target.threadId != null ? String(target.threadId) : undefined,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
} catch (err) {
|
|
462
|
+
this.log(`sendResolutionToIM: failed for ${target.channel}/${target.to}: ${err}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Forward a resolution notification received from mesh to local IM channels.
|
|
469
|
+
*/
|
|
470
|
+
async forwardResolutionToIM(
|
|
471
|
+
approvalId: string,
|
|
472
|
+
nodeId: string,
|
|
473
|
+
deviceInfo: DeviceInfo | undefined,
|
|
474
|
+
decision: "approve" | "deny",
|
|
475
|
+
resolvedBySource: string,
|
|
476
|
+
) {
|
|
477
|
+
this.log(`forwardResolutionToIM: ${decision} for ${nodeId}`);
|
|
478
|
+
await this.sendResolutionToIM(approvalId, nodeId, deviceInfo, decision, {
|
|
479
|
+
source: resolvedBySource,
|
|
480
|
+
at: Date.now(),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private addApproved(nodeId: string, deviceInfo?: DeviceInfo, publicKey?: string, resolvedBy?: ApprovalResolvedBy) {
|
|
485
|
+
this.data.approved[nodeId] = {
|
|
486
|
+
nodeId,
|
|
487
|
+
approvedAt: Date.now(),
|
|
488
|
+
deviceInfo,
|
|
489
|
+
publicKey,
|
|
490
|
+
resolvedBy,
|
|
491
|
+
};
|
|
492
|
+
delete this.data.denied[nodeId];
|
|
493
|
+
this.save();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── Notifications ───────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
private broadcastNotify(
|
|
499
|
+
broadcastFn: (frame: AnyClusterFrame) => void,
|
|
500
|
+
approvalId: string,
|
|
501
|
+
nodeId: string,
|
|
502
|
+
deviceInfo: DeviceInfo | undefined,
|
|
503
|
+
mode: "notify" | "required",
|
|
504
|
+
ip?: string,
|
|
505
|
+
) {
|
|
506
|
+
broadcastFn({
|
|
507
|
+
type: "peer_approval_notify",
|
|
508
|
+
from: "",
|
|
509
|
+
timestamp: Date.now(),
|
|
510
|
+
payload: { approvalId, nodeId, deviceInfo, mode, ip },
|
|
511
|
+
} as PeerApprovalNotify);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async sendNotifications(
|
|
515
|
+
approvalId: string,
|
|
516
|
+
nodeId: string,
|
|
517
|
+
deviceInfo: DeviceInfo | undefined,
|
|
518
|
+
mode: "notify" | "required",
|
|
519
|
+
ip?: string,
|
|
520
|
+
) {
|
|
521
|
+
if (!this.channelApi || this.notifyTargets.length === 0) {
|
|
522
|
+
this.log(`sendNotifications: skipped (channelApi=${!!this.channelApi} targets=${this.notifyTargets.length})`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
this.log(`sendNotifications: sending to ${this.notifyTargets.length} targets`);
|
|
526
|
+
|
|
527
|
+
const deviceLabel = `${deviceInfo?.hostname ?? "unknown"} (${deviceInfo?.os ?? "?"})`;
|
|
528
|
+
const expiresMin = Math.floor(this.config.timeout / 60_000);
|
|
529
|
+
const ipLine = ip ? `IP: ${ip}` : null;
|
|
530
|
+
|
|
531
|
+
const lines = mode === "notify"
|
|
532
|
+
? [
|
|
533
|
+
`\u{1f4f2} New device joined ClawMatrix cluster`,
|
|
534
|
+
`Node: ${nodeId}`,
|
|
535
|
+
`Device: ${deviceLabel}`,
|
|
536
|
+
...(ipLine ? [ipLine] : []),
|
|
537
|
+
``,
|
|
538
|
+
`If this wasn't you: /clawmatrix approval revoke ${nodeId}`,
|
|
539
|
+
]
|
|
540
|
+
: [
|
|
541
|
+
`\u{1f512} New device requesting to join ClawMatrix cluster`,
|
|
542
|
+
`Node: ${nodeId}`,
|
|
543
|
+
`Device: ${deviceLabel}`,
|
|
544
|
+
...(ipLine ? [ipLine] : []),
|
|
545
|
+
`Expires in ${expiresMin} minutes`,
|
|
546
|
+
];
|
|
547
|
+
|
|
548
|
+
const message = lines.join("\n");
|
|
549
|
+
|
|
550
|
+
// Interactive buttons for channels that support them (callback_data processed as command)
|
|
551
|
+
const approveCmd = `/clawmatrix approve ${approvalId}`;
|
|
552
|
+
const denyCmd = `/clawmatrix deny ${approvalId}`;
|
|
553
|
+
|
|
554
|
+
for (const target of this.notifyTargets) {
|
|
555
|
+
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
|
+
];
|
|
576
|
+
}
|
|
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)
|
|
581
|
+
await this.gatewaySend({
|
|
582
|
+
to: target.to,
|
|
583
|
+
message: mode === "required"
|
|
584
|
+
? `${message}\n\nApprove: ${approveCmd}\nDeny: ${denyCmd}`
|
|
585
|
+
: message,
|
|
586
|
+
channel: target.channel,
|
|
587
|
+
accountId: target.accountId,
|
|
588
|
+
threadId: target.threadId != null ? String(target.threadId) : undefined,
|
|
589
|
+
});
|
|
590
|
+
this.log(`sendNotifications: sent to ${target.channel}/${target.to} via gatewaySend`);
|
|
591
|
+
} else {
|
|
592
|
+
this.log(`sendNotifications: no channelApi or gatewaySend for "${target.channel}"`);
|
|
593
|
+
}
|
|
594
|
+
} catch (err) {
|
|
595
|
+
this.log(`sendNotifications: failed for ${target.channel}/${target.to}: ${err}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── IP-level approval noise suppression ─────────────────────────
|
|
601
|
+
|
|
602
|
+
/** Check if an IP has exceeded the deny threshold (auto-block). */
|
|
603
|
+
isIpBlocked(ip: string): boolean {
|
|
604
|
+
const history = this.ipDenyHistory.get(ip);
|
|
605
|
+
if (!history) return false;
|
|
606
|
+
const cutoff = Date.now() - IP_DENY_WINDOW;
|
|
607
|
+
const recent = history.filter((t) => t > cutoff);
|
|
608
|
+
if (recent.length !== history.length) {
|
|
609
|
+
this.ipDenyHistory.set(ip, recent);
|
|
610
|
+
}
|
|
611
|
+
return recent.length >= IP_DENY_THRESHOLD;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Record a deny event for an IP. */
|
|
615
|
+
recordIpDeny(ip: string) {
|
|
616
|
+
const history = this.ipDenyHistory.get(ip) ?? [];
|
|
617
|
+
history.push(Date.now());
|
|
618
|
+
this.ipDenyHistory.set(ip, history);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
destroy() {
|
|
622
|
+
// Reject all pending approvals
|
|
623
|
+
for (const pending of this.pending.values()) {
|
|
624
|
+
pending.resolve("deny");
|
|
625
|
+
}
|
|
626
|
+
this.pending.clear();
|
|
627
|
+
}
|
|
628
|
+
}
|