clawmatrix 0.1.22 → 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 +290 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +132 -87
- package/src/identity.ts +95 -0
- package/src/index.ts +539 -45
- package/src/knowledge-sync.ts +777 -205
- package/src/local-tools.ts +9 -2
- package/src/model-proxy.ts +358 -110
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +270 -38
- 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 +477 -3
- package/src/web.ts +2 -2
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
2
|
-
import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
|
|
1
|
+
import type { OpenClawPluginApi, OpenClawConfig, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
|
|
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,116 @@ 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";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Auto-discover models from OpenClaw's models.providers config.
|
|
24
|
+
* Iterates all configured providers and their models, building the
|
|
25
|
+
* full model list with endpoint info (baseUrl, apiKey, api type, etc.).
|
|
26
|
+
* Excludes clawmatrix proxy providers (remote models from peer nodes).
|
|
27
|
+
*/
|
|
28
|
+
type ProviderEntry = { baseUrl?: string; apiKey?: string; api?: string; models?: Array<Record<string, unknown>> };
|
|
29
|
+
|
|
30
|
+
function discoverModels(
|
|
31
|
+
openclawConfig: OpenClawConfig,
|
|
32
|
+
config: ClawMatrixConfig,
|
|
33
|
+
): ClawMatrixConfig["models"] {
|
|
34
|
+
const cfg = openclawConfig as Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
// Collect proxyModel node IDs to exclude clawmatrix-registered providers
|
|
37
|
+
const proxyNodeIds = new Set(config.proxyModels.map((m) => m.nodeId));
|
|
38
|
+
|
|
39
|
+
const result: ClawMatrixConfig["models"] = [];
|
|
40
|
+
const seenIds = new Set<string>(); // "provider/modelId"
|
|
41
|
+
|
|
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
|
+
}
|
|
74
|
+
|
|
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);
|
|
101
|
+
result.push({
|
|
102
|
+
id: modelId,
|
|
103
|
+
provider,
|
|
104
|
+
description: providerModel?.name as string | undefined,
|
|
105
|
+
baseUrl: providerConfig?.baseUrl,
|
|
106
|
+
apiKey: typeof providerConfig?.apiKey === "string" ? providerConfig.apiKey : undefined,
|
|
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,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.length > 0) {
|
|
117
|
+
debug("models", `Auto-discovered ${result.length} model(s): ${result.map((m) => `${m.provider}/${m.id}`).join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
14
122
|
|
|
15
123
|
const plugin = {
|
|
16
124
|
id: "clawmatrix",
|
|
@@ -27,6 +135,8 @@ const plugin = {
|
|
|
27
135
|
},
|
|
28
136
|
|
|
29
137
|
register(api: OpenClawPluginApi) {
|
|
138
|
+
initDebugLogger(api.logger);
|
|
139
|
+
|
|
30
140
|
let config: ReturnType<typeof parseConfig>;
|
|
31
141
|
try {
|
|
32
142
|
config = parseConfig(api.pluginConfig);
|
|
@@ -36,6 +146,35 @@ const plugin = {
|
|
|
36
146
|
return;
|
|
37
147
|
}
|
|
38
148
|
|
|
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
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
39
178
|
// Background service: manages mesh connections, WS listener, heartbeat
|
|
40
179
|
api.registerService(createClusterService(config, api.config, api.runtime.version));
|
|
41
180
|
|
|
@@ -60,26 +199,114 @@ const plugin = {
|
|
|
60
199
|
const models = ((cfg).models ??= {}) as Record<string, unknown>;
|
|
61
200
|
const providers = (models.providers ??= {}) as Record<string, unknown>;
|
|
62
201
|
for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
202
|
+
const apiType = nodeApiType[nodeId] ?? "openai-completions";
|
|
203
|
+
const existing = providers[nodeId] as Record<string, unknown> | undefined;
|
|
204
|
+
if (existing) {
|
|
205
|
+
// Provider already exists (e.g. from models.json or config reload) —
|
|
206
|
+
// ensure the dummy apiKey is always present so auth resolution succeeds.
|
|
207
|
+
if (!existing.apiKey) existing.apiKey = "sk-clawmatrix-proxy";
|
|
208
|
+
if (!existing.baseUrl) existing.baseUrl = baseUrl;
|
|
209
|
+
} else {
|
|
210
|
+
providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: apiType, models: nodeModels };
|
|
66
211
|
}
|
|
67
212
|
}
|
|
68
213
|
};
|
|
69
214
|
|
|
70
|
-
|
|
215
|
+
// Patch all known config objects.
|
|
216
|
+
// activateSecretsRuntimeSnapshot clones config on startup & hot-reload,
|
|
217
|
+
// so our injected providers get lost. There is no plugin-facing "config_reload"
|
|
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>
|
|
71
221
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
79
286
|
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
let lastSnapshotRef: unknown = null;
|
|
290
|
+
const patchAllConfigs = () => {
|
|
291
|
+
patchProviders(api.config as Record<string, unknown>);
|
|
292
|
+
patchDiscoveredProviders(api.config as Record<string, unknown>);
|
|
293
|
+
try {
|
|
294
|
+
const snapshot = api.runtime.config.loadConfig();
|
|
295
|
+
if (snapshot && snapshot !== lastSnapshotRef) {
|
|
296
|
+
lastSnapshotRef = snapshot;
|
|
297
|
+
patchProviders(snapshot as Record<string, unknown>);
|
|
298
|
+
patchDiscoveredProviders(snapshot as Record<string, unknown>);
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// Best-effort
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
patchAllConfigs();
|
|
306
|
+
|
|
307
|
+
const repatchTimer = setInterval(patchAllConfigs, 10_000);
|
|
308
|
+
repatchTimer.unref?.();
|
|
309
|
+
api.on("dispose", () => clearInterval(repatchTimer));
|
|
83
310
|
|
|
84
311
|
for (const [nodeId, models] of Object.entries(modelsByNode)) {
|
|
85
312
|
api.registerProvider({
|
|
@@ -103,8 +330,107 @@ const plugin = {
|
|
|
103
330
|
api.registerTool(createClusterExecTool(), { optional: true });
|
|
104
331
|
api.registerTool(createClusterReadTool(), { optional: true });
|
|
105
332
|
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
106
|
-
api.registerTool(
|
|
333
|
+
api.registerTool(createClusterEditTool(), { optional: true });
|
|
334
|
+
api.registerTool(createClusterBatchTool(), { optional: true });
|
|
107
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
|
+
}
|
|
108
434
|
|
|
109
435
|
// Gateway methods (queried by CLI via `openclaw gateway call`)
|
|
110
436
|
api.registerGatewayMethod(
|
|
@@ -120,19 +446,7 @@ const plugin = {
|
|
|
120
446
|
agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
|
|
121
447
|
models: config.models.map((m) => ({ id: m.id })),
|
|
122
448
|
tags: config.tags,
|
|
123
|
-
peers: peers
|
|
124
|
-
const status = runtime.peerManager.router.getPeerStatus(p);
|
|
125
|
-
return {
|
|
126
|
-
nodeId: p.nodeId,
|
|
127
|
-
agents: p.agents,
|
|
128
|
-
models: p.models,
|
|
129
|
-
tags: p.tags,
|
|
130
|
-
connected: status !== "unreachable",
|
|
131
|
-
status,
|
|
132
|
-
reachableVia: p.reachableVia,
|
|
133
|
-
latencyMs: p.latencyMs,
|
|
134
|
-
};
|
|
135
|
-
}),
|
|
449
|
+
peers: mergeSentinelPeers(peers, runtime),
|
|
136
450
|
});
|
|
137
451
|
} catch {
|
|
138
452
|
respond(false, { error: "ClawMatrix service not running" });
|
|
@@ -145,29 +459,156 @@ const plugin = {
|
|
|
145
459
|
({ respond }: GatewayRequestHandlerOptions) => {
|
|
146
460
|
try {
|
|
147
461
|
const runtime = getClusterRuntime();
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
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,
|
|
151
513
|
nodeId: p.nodeId,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
status,
|
|
157
|
-
reachableVia: p.reachableVia,
|
|
158
|
-
latencyMs: p.latencyMs,
|
|
159
|
-
};
|
|
514
|
+
deviceInfo: p.deviceInfo,
|
|
515
|
+
createdAt: p.createdAt,
|
|
516
|
+
})),
|
|
517
|
+
denied: mgr.getDeniedPeers(),
|
|
160
518
|
});
|
|
161
|
-
respond(true, peers);
|
|
162
519
|
} catch {
|
|
163
|
-
respond(
|
|
520
|
+
respond(false, { error: "ClawMatrix service not running" });
|
|
164
521
|
}
|
|
165
522
|
},
|
|
166
523
|
);
|
|
167
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" });
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
// Log model selection on each LLM call (fire-and-forget)
|
|
545
|
+
api.on("llm_input", (event) => {
|
|
546
|
+
api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
|
|
547
|
+
});
|
|
548
|
+
|
|
168
549
|
// CLI subcommand
|
|
169
550
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
170
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
|
+
|
|
171
612
|
// Inject cluster context into agent prompts.
|
|
172
613
|
//
|
|
173
614
|
// Minimal-injection strategy:
|
|
@@ -198,7 +639,7 @@ const plugin = {
|
|
|
198
639
|
`[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}`,
|
|
199
640
|
...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
|
|
200
641
|
`${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
|
|
201
|
-
"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.",
|
|
202
643
|
"IMPORTANT: Always tell user which remote node you're targeting before calling cluster tools.",
|
|
203
644
|
);
|
|
204
645
|
}
|
|
@@ -265,4 +706,57 @@ function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<strin
|
|
|
265
706
|
return result;
|
|
266
707
|
}
|
|
267
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
|
+
|
|
268
762
|
export default plugin;
|