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
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginApi, OpenClawConfig, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
2
2
|
import { ClawMatrixConfigSchema, parseConfig, type ClawMatrixConfig } from "./config.ts";
|
|
3
|
+
import { debug, initDebugLogger } from "./debug.ts";
|
|
3
4
|
import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
|
|
4
5
|
import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
|
|
5
6
|
import { createClusterHandoffReplyTool } from "./tools/cluster-handoff-reply.ts";
|
|
@@ -8,9 +9,15 @@ import { createClusterPeersTool } from "./tools/cluster-peers.ts";
|
|
|
8
9
|
import { createClusterExecTool } from "./tools/cluster-exec.ts";
|
|
9
10
|
import { createClusterReadTool } from "./tools/cluster-read.ts";
|
|
10
11
|
import { createClusterWriteTool } from "./tools/cluster-write.ts";
|
|
11
|
-
import {
|
|
12
|
+
import { createClusterEditTool } from "./tools/cluster-edit.ts";
|
|
13
|
+
import { createClusterBatchTool } from "./tools/cluster-batch.ts";
|
|
12
14
|
import { createClusterEventsTool } from "./tools/cluster-events.ts";
|
|
15
|
+
import { createClusterDiagnosticTool } from "./tools/cluster-diagnostic.ts";
|
|
16
|
+
import { createClusterAcpTool } from "./tools/cluster-acp.ts";
|
|
17
|
+
import { createClusterTerminalTool } from "./tools/cluster-terminal.ts";
|
|
18
|
+
import { createClusterToolInvokeTool } from "./tools/cluster-tool.ts";
|
|
13
19
|
import { registerClusterCli } from "./cli.ts";
|
|
20
|
+
import { spawnProcess } from "./compat.ts";
|
|
14
21
|
|
|
15
22
|
/**
|
|
16
23
|
* Auto-discover models from OpenClaw's models.providers config.
|
|
@@ -25,41 +32,89 @@ function discoverModels(
|
|
|
25
32
|
config: ClawMatrixConfig,
|
|
26
33
|
): ClawMatrixConfig["models"] {
|
|
27
34
|
const cfg = openclawConfig as Record<string, unknown>;
|
|
28
|
-
const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
|
|
29
|
-
if (!providers || typeof providers !== "object") return [];
|
|
30
35
|
|
|
31
36
|
// Collect proxyModel node IDs to exclude clawmatrix-registered providers
|
|
32
37
|
const proxyNodeIds = new Set(config.proxyModels.map((m) => m.nodeId));
|
|
33
38
|
|
|
34
39
|
const result: ClawMatrixConfig["models"] = [];
|
|
35
|
-
|
|
36
|
-
// Skip clawmatrix proxy providers (remote models from peer nodes)
|
|
37
|
-
if (proxyNodeIds.has(providerId)) continue;
|
|
40
|
+
const seenIds = new Set<string>(); // "provider/modelId"
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
// 1. Discover from models.providers (explicit provider configs)
|
|
43
|
+
const providers = (cfg.models as { providers?: Record<string, ProviderEntry> } | undefined)?.providers;
|
|
44
|
+
if (providers && typeof providers === "object") {
|
|
45
|
+
for (const [providerId, providerConfig] of Object.entries(providers)) {
|
|
46
|
+
// Skip clawmatrix proxy providers (remote models from peer nodes)
|
|
47
|
+
if (proxyNodeIds.has(providerId)) continue;
|
|
48
|
+
|
|
49
|
+
const models = providerConfig?.models;
|
|
50
|
+
if (!Array.isArray(models)) continue;
|
|
51
|
+
|
|
52
|
+
for (const m of models) {
|
|
53
|
+
if (!m.id || typeof m.id !== "string") continue;
|
|
54
|
+
const key = `${providerId}/${m.id}`;
|
|
55
|
+
if (seenIds.has(key)) continue;
|
|
56
|
+
seenIds.add(key);
|
|
57
|
+
result.push({
|
|
58
|
+
id: m.id,
|
|
59
|
+
provider: providerId,
|
|
60
|
+
description: m.name as string | undefined,
|
|
61
|
+
baseUrl: providerConfig?.baseUrl,
|
|
62
|
+
apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
|
|
63
|
+
api: (m.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
|
|
64
|
+
contextWindow: m.contextWindow as number | undefined,
|
|
65
|
+
maxTokens: m.maxTokens as number | undefined,
|
|
66
|
+
reasoning: m.reasoning as boolean | undefined,
|
|
67
|
+
input: m.input as ("text" | "image")[] | undefined,
|
|
68
|
+
cost: m.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined,
|
|
69
|
+
compat: m.compat as ClawMatrixConfig["models"][0]["compat"],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
41
74
|
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
// 2. Discover from agents.defaults.model (primary + fallbacks) — the user's
|
|
76
|
+
// configured model list, which includes pi-ai built-in models like
|
|
77
|
+
// anthropic/claude-opus-4-6 that aren't in models.providers.
|
|
78
|
+
const agentDefaults = (cfg.agents as { defaults?: { model?: { primary?: string; fallbacks?: string[] } } } | undefined)?.defaults;
|
|
79
|
+
const modelConfig = agentDefaults?.model;
|
|
80
|
+
if (modelConfig) {
|
|
81
|
+
const refs: string[] = [];
|
|
82
|
+
if (modelConfig.primary) refs.push(modelConfig.primary);
|
|
83
|
+
if (Array.isArray(modelConfig.fallbacks)) refs.push(...modelConfig.fallbacks);
|
|
84
|
+
|
|
85
|
+
for (const modelRef of refs) {
|
|
86
|
+
if (typeof modelRef !== "string") continue;
|
|
87
|
+
const slashIdx = modelRef.indexOf("/");
|
|
88
|
+
if (slashIdx <= 0) continue;
|
|
89
|
+
const provider = modelRef.slice(0, slashIdx);
|
|
90
|
+
const modelId = modelRef.slice(slashIdx + 1);
|
|
91
|
+
if (!modelId) continue;
|
|
92
|
+
|
|
93
|
+
const key = `${provider}/${modelId}`;
|
|
94
|
+
if (seenIds.has(key)) continue;
|
|
95
|
+
if (proxyNodeIds.has(provider)) continue;
|
|
96
|
+
seenIds.add(key);
|
|
97
|
+
|
|
98
|
+
// Look up provider config for richer model info if available
|
|
99
|
+
const providerConfig = providers?.[provider];
|
|
100
|
+
const providerModel = providerConfig?.models?.find((m: Record<string, unknown>) => m.id === modelId);
|
|
44
101
|
result.push({
|
|
45
|
-
id:
|
|
46
|
-
provider
|
|
47
|
-
description:
|
|
102
|
+
id: modelId,
|
|
103
|
+
provider,
|
|
104
|
+
description: providerModel?.name as string | undefined,
|
|
48
105
|
baseUrl: providerConfig?.baseUrl,
|
|
49
106
|
apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
|
|
50
|
-
api: (
|
|
51
|
-
contextWindow:
|
|
52
|
-
maxTokens:
|
|
53
|
-
reasoning:
|
|
54
|
-
input:
|
|
55
|
-
cost: m.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined,
|
|
56
|
-
compat: m.compat as ClawMatrixConfig["models"][0]["compat"],
|
|
107
|
+
api: (providerModel?.api ?? providerConfig?.api) as ClawMatrixConfig["models"][0]["api"],
|
|
108
|
+
contextWindow: providerModel?.contextWindow as number | undefined,
|
|
109
|
+
maxTokens: providerModel?.maxTokens as number | undefined,
|
|
110
|
+
reasoning: providerModel?.reasoning as boolean | undefined,
|
|
111
|
+
input: providerModel?.input as ("text" | "image")[] | undefined,
|
|
57
112
|
});
|
|
58
113
|
}
|
|
59
114
|
}
|
|
60
115
|
|
|
61
116
|
if (result.length > 0) {
|
|
62
|
-
|
|
117
|
+
debug("models", `Auto-discovered ${result.length} model(s): ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
|
|
63
118
|
}
|
|
64
119
|
|
|
65
120
|
return result;
|
|
@@ -80,6 +135,8 @@ const plugin = {
|
|
|
80
135
|
},
|
|
81
136
|
|
|
82
137
|
register(api: OpenClawPluginApi) {
|
|
138
|
+
initDebugLogger(api.logger);
|
|
139
|
+
|
|
83
140
|
let config: ReturnType<typeof parseConfig>;
|
|
84
141
|
try {
|
|
85
142
|
config = parseConfig(api.pluginConfig);
|
|
@@ -89,9 +146,33 @@ const plugin = {
|
|
|
89
146
|
return;
|
|
90
147
|
}
|
|
91
148
|
|
|
92
|
-
// Auto-discover models from
|
|
93
|
-
|
|
94
|
-
|
|
149
|
+
// Auto-discover models from OpenClaw providers and merge with explicit config
|
|
150
|
+
{
|
|
151
|
+
const discovered = discoverModels(api.config, config);
|
|
152
|
+
if (discovered.length > 0) {
|
|
153
|
+
// Merge: add discovered models not already explicitly configured
|
|
154
|
+
const existingIds = new Set(config.models.map((m) => `${m.provider}/${m.id}`));
|
|
155
|
+
const newModels = discovered.filter((m) => !existingIds.has(`${m.provider}/${m.id}`));
|
|
156
|
+
if (newModels.length > 0) {
|
|
157
|
+
config = { ...config, models: [...config.models, ...newModels] };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Filter out models that have no direct API endpoint — these rely on OpenClaw's
|
|
162
|
+
// internal API key management and cannot be served to remote cluster nodes.
|
|
163
|
+
// Without this filter, remote nodes discover and try to use these models via
|
|
164
|
+
// model_req, only to get "No direct API endpoint configured" errors.
|
|
165
|
+
const openclawProviders = ((api.config as Record<string, unknown>).models as { providers?: Record<string, { baseUrl?: string }> } | undefined)?.providers;
|
|
166
|
+
const servable = config.models.filter((m) => {
|
|
167
|
+
if (m.baseUrl) return true;
|
|
168
|
+
if (openclawProviders?.[m.provider]?.baseUrl) return true;
|
|
169
|
+
return false;
|
|
170
|
+
});
|
|
171
|
+
if (servable.length < config.models.length) {
|
|
172
|
+
const dropped = config.models.length - servable.length;
|
|
173
|
+
debug("models", `Filtered out ${dropped} model(s) without direct API endpoint (not servable to cluster)`);
|
|
174
|
+
config = { ...config, models: servable };
|
|
175
|
+
}
|
|
95
176
|
}
|
|
96
177
|
|
|
97
178
|
// Background service: manages mesh connections, WS listener, heartbeat
|
|
@@ -135,14 +216,86 @@ const plugin = {
|
|
|
135
216
|
// activateSecretsRuntimeSnapshot clones config on startup & hot-reload,
|
|
136
217
|
// so our injected providers get lost. There is no plugin-facing "config_reload"
|
|
137
218
|
// event, so we re-patch periodically and skip when the reference hasn't changed.
|
|
219
|
+
/** Track which model IDs we injected per provider so we can remove stale ones. */
|
|
220
|
+
const injectedDiscoveredIds = new Map<string, Set<string>>(); // nodeId → Set<modelId>
|
|
221
|
+
|
|
222
|
+
/** Patch discovered (auto-discovered from peers) models into OpenClaw config.
|
|
223
|
+
* Full sync: adds new models, removes models whose peers went offline. */
|
|
224
|
+
const patchDiscoveredProviders = (cfg: Record<string, unknown>) => {
|
|
225
|
+
try {
|
|
226
|
+
const runtime = getClusterRuntime();
|
|
227
|
+
const discovered = runtime.modelProxy.allProxyModels;
|
|
228
|
+
const models = ((cfg).models ??= {}) as Record<string, unknown>;
|
|
229
|
+
const providers = (models.providers ??= {}) as Record<string, unknown>;
|
|
230
|
+
|
|
231
|
+
// Build desired state: group discovered models by nodeId (skip static ones)
|
|
232
|
+
const desiredByNode = new Map<string, ReturnType<typeof formatModel>[]>();
|
|
233
|
+
for (const m of discovered) {
|
|
234
|
+
if (modelsByNode[m.nodeId]?.some((sm) => sm.id === m.id)) continue;
|
|
235
|
+
const arr = desiredByNode.get(m.nodeId) ?? [];
|
|
236
|
+
arr.push(formatModel(m));
|
|
237
|
+
desiredByNode.set(m.nodeId, arr);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Remove stale: models we previously injected but are no longer discovered
|
|
241
|
+
for (const [nodeId, prevIds] of injectedDiscoveredIds) {
|
|
242
|
+
const desiredModels = desiredByNode.get(nodeId);
|
|
243
|
+
const desiredIds = new Set(desiredModels?.map((m) => m.id) ?? []);
|
|
244
|
+
const staleIds = [...prevIds].filter((id) => !desiredIds.has(id));
|
|
245
|
+
if (staleIds.length === 0) continue;
|
|
246
|
+
|
|
247
|
+
const provider = providers[nodeId] as Record<string, unknown> | undefined;
|
|
248
|
+
if (!provider) continue;
|
|
249
|
+
const providerModels = provider.models as Array<{ id: string }> | undefined;
|
|
250
|
+
if (!providerModels) continue;
|
|
251
|
+
const staleSet = new Set(staleIds);
|
|
252
|
+
provider.models = providerModels.filter((m) => !staleSet.has(m.id));
|
|
253
|
+
// If provider has no models left and is purely discovered (not in static config), remove it
|
|
254
|
+
if ((provider.models as unknown[]).length === 0 && !modelsByNode[nodeId]) {
|
|
255
|
+
delete providers[nodeId];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Add new discovered models
|
|
260
|
+
const nextInjected = new Map<string, Set<string>>();
|
|
261
|
+
for (const [nodeId, nodeModels] of desiredByNode) {
|
|
262
|
+
const ids = new Set(nodeModels.map((m) => m.id));
|
|
263
|
+
nextInjected.set(nodeId, ids);
|
|
264
|
+
|
|
265
|
+
const existing = providers[nodeId] as Record<string, unknown> | undefined;
|
|
266
|
+
if (existing) {
|
|
267
|
+
if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
|
|
268
|
+
if (!existing.baseUrl) existing.baseUrl = baseUrl;
|
|
269
|
+
const existingModels = (existing.models ?? []) as Array<{ id: string }>;
|
|
270
|
+
const existingIds = new Set(existingModels.map((m) => m.id));
|
|
271
|
+
for (const nm of nodeModels) {
|
|
272
|
+
if (!existingIds.has(nm.id)) existingModels.push(nm);
|
|
273
|
+
}
|
|
274
|
+
existing.models = existingModels;
|
|
275
|
+
} else {
|
|
276
|
+
const apiType = nodeApiType[nodeId] ?? "openai-completions";
|
|
277
|
+
providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Update tracking state
|
|
282
|
+
injectedDiscoveredIds.clear();
|
|
283
|
+
for (const [k, v] of nextInjected) injectedDiscoveredIds.set(k, v);
|
|
284
|
+
} catch {
|
|
285
|
+
// Runtime not ready yet — will be patched on next cycle
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
138
289
|
let lastSnapshotRef: unknown = null;
|
|
139
290
|
const patchAllConfigs = () => {
|
|
140
291
|
patchProviders(api.config as Record<string, unknown>);
|
|
292
|
+
patchDiscoveredProviders(api.config as Record<string, unknown>);
|
|
141
293
|
try {
|
|
142
294
|
const snapshot = api.runtime.config.loadConfig();
|
|
143
295
|
if (snapshot && snapshot !== lastSnapshotRef) {
|
|
144
296
|
lastSnapshotRef = snapshot;
|
|
145
297
|
patchProviders(snapshot as Record<string, unknown>);
|
|
298
|
+
patchDiscoveredProviders(snapshot as Record<string, unknown>);
|
|
146
299
|
}
|
|
147
300
|
} catch {
|
|
148
301
|
// Best-effort
|
|
@@ -177,8 +330,107 @@ const plugin = {
|
|
|
177
330
|
api.registerTool(createClusterExecTool(), { optional: true });
|
|
178
331
|
api.registerTool(createClusterReadTool(), { optional: true });
|
|
179
332
|
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
180
|
-
api.registerTool(
|
|
333
|
+
api.registerTool(createClusterEditTool(), { optional: true });
|
|
334
|
+
api.registerTool(createClusterBatchTool(), { optional: true });
|
|
181
335
|
api.registerTool(createClusterEventsTool(), { optional: true });
|
|
336
|
+
api.registerTool(createClusterDiagnosticTool(), { optional: true });
|
|
337
|
+
api.registerTool(createClusterAcpTool(), { optional: true });
|
|
338
|
+
api.registerTool(createClusterTerminalTool(), { optional: true });
|
|
339
|
+
api.registerTool(createClusterToolInvokeTool(), { optional: true });
|
|
340
|
+
|
|
341
|
+
// Wire up peer approval with OpenClaw channel API
|
|
342
|
+
if (config.peerApproval.enabled) {
|
|
343
|
+
const setupApproval = () => {
|
|
344
|
+
try {
|
|
345
|
+
const runtime = getClusterRuntime();
|
|
346
|
+
const mgr = runtime.peerManager.approvalManager;
|
|
347
|
+
|
|
348
|
+
// Set channel API from OpenClaw runtime
|
|
349
|
+
const channelApi = (api.runtime as Record<string, unknown>)?.channel;
|
|
350
|
+
if (channelApi && typeof channelApi === "object") {
|
|
351
|
+
mgr.setChannelApi(channelApi as import("./peer-approval.ts").ChannelApi);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Set gateway send function for plugin channels (e.g. feishu)
|
|
355
|
+
// Uses OpenClaw's `send` gateway method via subprocess — works for all channels
|
|
356
|
+
mgr.setGatewaySend(async (params) => {
|
|
357
|
+
const sendParams: Record<string, unknown> = {
|
|
358
|
+
to: params.to,
|
|
359
|
+
message: params.message,
|
|
360
|
+
channel: params.channel,
|
|
361
|
+
idempotencyKey: crypto.randomUUID(),
|
|
362
|
+
};
|
|
363
|
+
if (params.accountId) sendParams.accountId = params.accountId;
|
|
364
|
+
if (params.threadId) sendParams.threadId = params.threadId;
|
|
365
|
+
|
|
366
|
+
const proc = spawnProcess(
|
|
367
|
+
["openclaw", "gateway", "call", "send", "--json", "--params", JSON.stringify(sendParams)],
|
|
368
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
369
|
+
);
|
|
370
|
+
const code = await proc.exited;
|
|
371
|
+
if (code !== 0) {
|
|
372
|
+
const stderrChunks: Uint8Array[] = [];
|
|
373
|
+
if (proc.stderr) {
|
|
374
|
+
const reader = proc.stderr.getReader();
|
|
375
|
+
while (true) {
|
|
376
|
+
const { done, value } = await reader.read();
|
|
377
|
+
if (done) break;
|
|
378
|
+
stderrChunks.push(value);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const errMsg = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
382
|
+
throw new Error(`gateway send failed (exit ${code}): ${errMsg}`);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Resolve notification targets:
|
|
387
|
+
// 1. Explicit ClawMatrix notifyTargets config
|
|
388
|
+
// 2. Fallback: OpenClaw exec approval forwarding config
|
|
389
|
+
// 3. Auto-detect: scan OpenClaw channels config for configured groups
|
|
390
|
+
const targets: import("./peer-approval.ts").NotifyTarget[] = [];
|
|
391
|
+
if (config.peerApproval.notifyTargets.length > 0) {
|
|
392
|
+
for (const t of config.peerApproval.notifyTargets) {
|
|
393
|
+
targets.push({ channel: t.channel, to: t.to, accountId: t.accountId, threadId: t.threadId });
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
// Fallback: borrow from OpenClaw exec approval forwarding config
|
|
397
|
+
const approvalsConfig = (api.config as Record<string, unknown>).approvals as
|
|
398
|
+
| { exec?: { targets?: Array<{ channel: string; to: string; accountId?: string; threadId?: string | number }> } }
|
|
399
|
+
| undefined;
|
|
400
|
+
if (approvalsConfig?.exec?.targets) {
|
|
401
|
+
for (const t of approvalsConfig.exec.targets) {
|
|
402
|
+
targets.push({ channel: t.channel, to: t.to, accountId: t.accountId, threadId: t.threadId });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Auto-detect: scan OpenClaw channels config for enabled channels with groups
|
|
407
|
+
if (targets.length === 0) {
|
|
408
|
+
const channelsConfig = (api.config as Record<string, unknown>).channels as
|
|
409
|
+
Record<string, { enabled?: boolean; groups?: Record<string, unknown> }> | undefined;
|
|
410
|
+
if (channelsConfig) {
|
|
411
|
+
for (const [channelId, chConf] of Object.entries(channelsConfig)) {
|
|
412
|
+
if (!chConf || chConf.enabled === false) continue;
|
|
413
|
+
if (chConf.groups && typeof chConf.groups === "object") {
|
|
414
|
+
// Use the first configured group as notification target
|
|
415
|
+
const firstGroupId = Object.keys(chConf.groups)[0];
|
|
416
|
+
if (firstGroupId) {
|
|
417
|
+
targets.push({ channel: channelId, to: firstGroupId });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (targets.length > 0) {
|
|
424
|
+
mgr.setNotifyTargets(targets);
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
debug("approval", `setupApproval failed: ${err}`);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Delay setup until service has started
|
|
432
|
+
setTimeout(setupApproval, 1000);
|
|
433
|
+
}
|
|
182
434
|
|
|
183
435
|
// Gateway methods (queried by CLI via `openclaw gateway call`)
|
|
184
436
|
api.registerGatewayMethod(
|
|
@@ -194,19 +446,7 @@ const plugin = {
|
|
|
194
446
|
agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
|
|
195
447
|
models: config.models.map((m) => ({ id: m.id })),
|
|
196
448
|
tags: config.tags,
|
|
197
|
-
peers: peers
|
|
198
|
-
const status = runtime.peerManager.router.getPeerStatus(p);
|
|
199
|
-
return {
|
|
200
|
-
nodeId: p.nodeId,
|
|
201
|
-
agents: p.agents,
|
|
202
|
-
models: p.models,
|
|
203
|
-
tags: p.tags,
|
|
204
|
-
connected: status !== "unreachable",
|
|
205
|
-
status,
|
|
206
|
-
reachableVia: p.reachableVia,
|
|
207
|
-
latencyMs: p.latencyMs,
|
|
208
|
-
};
|
|
209
|
-
}),
|
|
449
|
+
peers: mergeSentinelPeers(peers, runtime),
|
|
210
450
|
});
|
|
211
451
|
} catch {
|
|
212
452
|
respond(false, { error: "ClawMatrix service not running" });
|
|
@@ -219,22 +459,84 @@ const plugin = {
|
|
|
219
459
|
({ respond }: GatewayRequestHandlerOptions) => {
|
|
220
460
|
try {
|
|
221
461
|
const runtime = getClusterRuntime();
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
462
|
+
const allPeers = runtime.peerManager.router.getAllPeers();
|
|
463
|
+
respond(true, mergeSentinelPeers(allPeers, runtime));
|
|
464
|
+
} catch {
|
|
465
|
+
respond(true, []);
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// Peer approval gateway methods
|
|
471
|
+
api.registerGatewayMethod(
|
|
472
|
+
"clawmatrix.approval.resolve",
|
|
473
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
474
|
+
try {
|
|
475
|
+
const runtime = getClusterRuntime();
|
|
476
|
+
const { approvalId, decision, source } = (params ?? {}) as {
|
|
477
|
+
approvalId?: string; decision?: string; source?: string;
|
|
478
|
+
};
|
|
479
|
+
if (!approvalId || !decision || (decision !== "approve" && decision !== "deny")) {
|
|
480
|
+
respond(false, { error: "Invalid params: need approvalId and decision (approve|deny)" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const resolvedBy = { source: source || `gateway:${config.nodeId}`, at: Date.now() };
|
|
484
|
+
const ok = runtime.peerManager.approvalManager.handleResponse(approvalId, decision, resolvedBy);
|
|
485
|
+
if (!ok) {
|
|
486
|
+
// Not a local pending approval — broadcast response to mesh so the
|
|
487
|
+
// originating node can resolve it (e.g. user approved from a remote Telegram)
|
|
488
|
+
runtime.peerManager.router.broadcast({
|
|
489
|
+
type: "peer_approval_res",
|
|
490
|
+
id: approvalId,
|
|
491
|
+
from: config.nodeId,
|
|
492
|
+
timestamp: Date.now(),
|
|
493
|
+
payload: { approvalId, decision },
|
|
494
|
+
} as import("./types.ts").PeerApprovalResponse);
|
|
495
|
+
}
|
|
496
|
+
respond(true, { ok: true, approvalId, decision });
|
|
497
|
+
} catch {
|
|
498
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
api.registerGatewayMethod(
|
|
504
|
+
"clawmatrix.approval.list",
|
|
505
|
+
({ respond }: GatewayRequestHandlerOptions) => {
|
|
506
|
+
try {
|
|
507
|
+
const runtime = getClusterRuntime();
|
|
508
|
+
const mgr = runtime.peerManager.approvalManager;
|
|
509
|
+
respond(true, {
|
|
510
|
+
approved: mgr.getApprovedPeers(),
|
|
511
|
+
pending: mgr.getPendingApprovals().map((p) => ({
|
|
512
|
+
approvalId: p.approvalId,
|
|
225
513
|
nodeId: p.nodeId,
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
status,
|
|
231
|
-
reachableVia: p.reachableVia,
|
|
232
|
-
latencyMs: p.latencyMs,
|
|
233
|
-
};
|
|
514
|
+
deviceInfo: p.deviceInfo,
|
|
515
|
+
createdAt: p.createdAt,
|
|
516
|
+
})),
|
|
517
|
+
denied: mgr.getDeniedPeers(),
|
|
234
518
|
});
|
|
235
|
-
respond(true, peers);
|
|
236
519
|
} catch {
|
|
237
|
-
respond(
|
|
520
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
api.registerGatewayMethod(
|
|
526
|
+
"clawmatrix.approval.revoke",
|
|
527
|
+
({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
528
|
+
try {
|
|
529
|
+
const runtime = getClusterRuntime();
|
|
530
|
+
const { nodeId, source } = (params ?? {}) as { nodeId?: string; source?: string };
|
|
531
|
+
if (!nodeId) {
|
|
532
|
+
respond(false, { error: "Missing nodeId" });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const resolvedBy = { source: source || `gateway:${config.nodeId}`, at: Date.now() };
|
|
536
|
+
const ok = runtime.peerManager.approvalManager.revoke(nodeId, resolvedBy);
|
|
537
|
+
respond(true, { ok, nodeId });
|
|
538
|
+
} catch {
|
|
539
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
238
540
|
}
|
|
239
541
|
},
|
|
240
542
|
);
|
|
@@ -247,6 +549,66 @@ const plugin = {
|
|
|
247
549
|
// CLI subcommand
|
|
248
550
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
249
551
|
|
|
552
|
+
// Plugin command: /clawmatrix approve|deny|revoke
|
|
553
|
+
// Handles Telegram callback buttons and other chat surfaces.
|
|
554
|
+
// Plugin commands are processed before the agent, so they bypass the LLM.
|
|
555
|
+
api.registerCommand({
|
|
556
|
+
name: "clawmatrix",
|
|
557
|
+
description: "ClawMatrix cluster management (approve/deny peer requests)",
|
|
558
|
+
acceptsArgs: true,
|
|
559
|
+
requireAuth: true,
|
|
560
|
+
handler: (ctx) => {
|
|
561
|
+
const args = ctx.args?.trim();
|
|
562
|
+
if (!args) {
|
|
563
|
+
return { text: "Usage: /clawmatrix approve|deny <id> or /clawmatrix revoke <nodeId>" };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const parts = args.split(/\s+/);
|
|
567
|
+
const subCmd = parts[0]?.toLowerCase();
|
|
568
|
+
const target = parts[1];
|
|
569
|
+
|
|
570
|
+
if (!target) {
|
|
571
|
+
return { text: "Usage: /clawmatrix approve|deny <approvalId> or /clawmatrix revoke <nodeId>" };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const runtime = getClusterRuntime();
|
|
576
|
+
const mgr = runtime.peerManager.approvalManager;
|
|
577
|
+
const resolvedBy = { source: `command:${ctx.channel}:${ctx.senderId || "unknown"}`, at: Date.now() };
|
|
578
|
+
|
|
579
|
+
if (subCmd === "approve" || subCmd === "deny") {
|
|
580
|
+
const decision = subCmd as "approve" | "deny";
|
|
581
|
+
const ok = mgr.handleResponse(target, decision, resolvedBy);
|
|
582
|
+
if (!ok) {
|
|
583
|
+
// Not local — broadcast to mesh
|
|
584
|
+
runtime.peerManager.router.broadcast({
|
|
585
|
+
type: "peer_approval_res",
|
|
586
|
+
id: target,
|
|
587
|
+
from: config.nodeId,
|
|
588
|
+
timestamp: Date.now(),
|
|
589
|
+
payload: { approvalId: target, decision },
|
|
590
|
+
} as import("./types.ts").PeerApprovalResponse);
|
|
591
|
+
}
|
|
592
|
+
return { text: `${decision === "approve" ? "\u2705" : "\u274c"} ${decision === "approve" ? "Approved" : "Denied"}: ${target}` };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (subCmd === "approval" && parts[1] === "revoke" && parts[2]) {
|
|
596
|
+
const ok = mgr.revoke(parts[2], resolvedBy);
|
|
597
|
+
return { text: ok ? `Revoked: ${parts[2]}` : `Node not found: ${parts[2]}` };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (subCmd === "revoke") {
|
|
601
|
+
const ok = mgr.revoke(target, resolvedBy);
|
|
602
|
+
return { text: ok ? `Revoked: ${target}` : `Node not found: ${target}` };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return { text: "Unknown subcommand. Use: approve, deny, or revoke" };
|
|
606
|
+
} catch {
|
|
607
|
+
return { text: "ClawMatrix service not running" };
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
250
612
|
// Inject cluster context into agent prompts.
|
|
251
613
|
//
|
|
252
614
|
// Minimal-injection strategy:
|
|
@@ -277,7 +639,7 @@ const plugin = {
|
|
|
277
639
|
`[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}`,
|
|
278
640
|
...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
|
|
279
641
|
`${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
|
|
280
|
-
"Prefer cluster_exec/read/write for
|
|
642
|
+
"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.",
|
|
281
643
|
"IMPORTANT: Always tell user which remote node you're targeting before calling cluster tools.",
|
|
282
644
|
);
|
|
283
645
|
}
|
|
@@ -344,4 +706,57 @@ function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<strin
|
|
|
344
706
|
return result;
|
|
345
707
|
}
|
|
346
708
|
|
|
709
|
+
/** Merge sentinel peers into their main node entry (hides `:sentinel` suffix). */
|
|
710
|
+
function mergeSentinelPeers(
|
|
711
|
+
allPeers: import("./router.ts").RouteEntry[],
|
|
712
|
+
runtime: import("./cluster-service.ts").ClusterRuntime,
|
|
713
|
+
) {
|
|
714
|
+
const result: Record<string, unknown>[] = [];
|
|
715
|
+
const sentinelMap = new Map<string, typeof allPeers[number]>();
|
|
716
|
+
|
|
717
|
+
for (const p of allPeers) {
|
|
718
|
+
if (p.nodeId.endsWith(":sentinel")) {
|
|
719
|
+
sentinelMap.set(p.nodeId.replace(/:sentinel$/, ""), p);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const seen = new Set<string>();
|
|
724
|
+
for (const p of allPeers) {
|
|
725
|
+
if (p.nodeId.endsWith(":sentinel")) continue;
|
|
726
|
+
seen.add(p.nodeId);
|
|
727
|
+
const status = runtime.peerManager.router.getPeerStatus(p);
|
|
728
|
+
const sentinel = sentinelMap.get(p.nodeId);
|
|
729
|
+
const sentinelStatus = sentinel ? runtime.peerManager.router.getPeerStatus(sentinel) : undefined;
|
|
730
|
+
result.push({
|
|
731
|
+
nodeId: p.nodeId,
|
|
732
|
+
agents: p.agents,
|
|
733
|
+
models: p.models,
|
|
734
|
+
tags: p.tags,
|
|
735
|
+
connected: status !== "unreachable",
|
|
736
|
+
status,
|
|
737
|
+
reachableVia: p.reachableVia,
|
|
738
|
+
latencyMs: p.latencyMs,
|
|
739
|
+
...(sentinel ? { sentinel: sentinelStatus === "direct" || sentinelStatus === "relay" ? "online" : "offline" } : {}),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Sentinel-only: main node gone but sentinel alive
|
|
744
|
+
for (const [mainId, sentinel] of sentinelMap) {
|
|
745
|
+
if (seen.has(mainId)) continue;
|
|
746
|
+
const sentinelStatus = runtime.peerManager.router.getPeerStatus(sentinel);
|
|
747
|
+
result.push({
|
|
748
|
+
nodeId: mainId,
|
|
749
|
+
agents: [],
|
|
750
|
+
models: [],
|
|
751
|
+
tags: [],
|
|
752
|
+
connected: false,
|
|
753
|
+
status: "unreachable",
|
|
754
|
+
latencyMs: sentinel.latencyMs,
|
|
755
|
+
sentinel: sentinelStatus === "direct" || sentinelStatus === "relay" ? "online" : "offline",
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
|
|
347
762
|
export default plugin;
|