clawmatrix 0.1.11 → 0.1.13

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/BOOTSTRAP.md CHANGED
@@ -92,15 +92,16 @@ openclaw clawmatrix status
92
92
  "tags": ["office", "gpu"],
93
93
  "toolProxy": {
94
94
  "enabled": true,
95
- "allow": ["exec", "read", "write", "edit", "web_search"],
96
- "deny": ["browser", "sessions_spawn"]
95
+ "allow": ["*"],
96
+ "deny": []
97
97
  }
98
98
  }
99
99
  ```
100
100
 
101
101
  注意事项:
102
- - `models` 里填这台机器上 OpenClaw 已经配置好的模型(必须本地能用才行)
103
- - **必须开启 OpenClaw chatCompletions HTTP 端点**,否则模型代理无法工作(见下方「前置配置」)
102
+ - `models` 里的 `provider` 必须与 OpenClaw `models.providers` 中的 key 一致,ClawMatrix 会自动读取对应的 `baseUrl` 和 `apiKey`
103
+ - 如果需要覆盖(比如用不同的 API 地址),可以显式设置 `baseUrl` `apiKey`
104
+ - ClawMatrix 直接调模型 API,不经过 OpenClaw Gateway 的 agent 系统
104
105
  - `toolProxy.enabled: true` 才会接受远程工具调用
105
106
  - `toolProxy.deny` 优先于 `allow`,建议禁用高风险工具
106
107
 
@@ -154,13 +155,13 @@ openclaw clawmatrix status
154
155
  | `listenPort` | number | `19000` | 入站 WebSocket 端口 |
155
156
  | `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
156
157
  | `agents` | array | `[]` | 本节点提供的 Agent:`{ id, description, tags }` |
157
- | `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider, description? }` |
158
+ | `models` | array | `[]` | 本节点共享给集群的模型:`{ id, provider }`(自动读取 OpenClaw provider 的 baseUrl/apiKey,可选覆盖)|
158
159
  | `proxyModels` | array | `[]` | 从集群消费的远程模型:`{ id, nodeId?, description? }` |
159
160
  | `tags` | array | `[]` | 自由标签,用于能力路由 |
160
161
  | `proxyPort` | number | `19001` | 本地模型代理 HTTP 端口 |
161
162
  | `handoffTimeout` | number | `600000` | Handoff 超时(毫秒,默认 10 分钟) |
162
163
  | `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
163
- | `toolProxy.allow` | array | `[]` | 允许的工具名(`[]` 或 `["*"]` = 全部允许) |
164
+ | `toolProxy.allow` | array | `[]` | 允许的工具名,`["*"]` 或 `[]` 表示全部。`exec`/`read`/`write`/`edit` 本地执行,其余走 Gateway |
164
165
  | `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
165
166
  | `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
166
167
 
@@ -181,26 +182,6 @@ openclaw clawmatrix status
181
182
  `target` 参数支持 Agent ID(如 `"coder"`)或标签查询(如 `"tags:coding"`)。
182
183
  `node` 参数支持 nodeId(如 `"office-01"`)或标签查询(如 `"tags:gpu"`)。
183
184
 
184
- ## 前置配置:开启 chatCompletions 端点
185
-
186
- **所有共享模型的节点都必须配置此项**。ClawMatrix 的模型代理通过 OpenClaw Gateway 的 `/v1/chat/completions` HTTP 端点转发请求,该端点默认关闭。
187
-
188
- 在 `openclaw.json` 的 `gateway` 字段下添加:
189
-
190
- ```json
191
- {
192
- "gateway": {
193
- "http": {
194
- "endpoints": {
195
- "chatCompletions": { "enabled": true }
196
- }
197
- }
198
- }
199
- }
200
- ```
201
-
202
- 如果不开启,模型代理请求会返回 404 错误。
203
-
204
185
  ## 前置配置:注册集群模型到 OpenClaw
205
186
 
206
187
  消费远程模型的节点需要在 `models.providers` 中注册,否则 `/models` 命令看不到集群模型。
@@ -249,7 +230,7 @@ openclaw clawmatrix status
249
230
 
250
231
  **模型代理不工作**:
251
232
  - 确认远程节点的 `models` 中声明了该模型
252
- - 确认该模型在远程节点上本地可用(OpenClaw 已配置对应 provider
233
+ - 确认该模型的 `provider` 与 OpenClaw `models.providers` 中的 key 一致(ClawMatrix 会自动读取 baseUrl 和 apiKey
253
234
  - 确认本节点使用 `clawmatrix/<模型ID>` 格式引用模型
254
235
  - 如果用了 `proxyModels.nodeId`,确认该节点在线
255
236
 
package/README.md CHANGED
@@ -61,7 +61,7 @@ openclaw plugins install clawmatrix
61
61
  { "id": "claude-sonnet", "provider": "anthropic" },
62
62
  { "id": "deepseek-coder", "provider": "ollama" }
63
63
  ],
64
- "toolProxy": { "enabled": true, "allow": ["exec", "read", "write", "edit"] }
64
+ "toolProxy": { "enabled": true, "allow": ["*"] }
65
65
  }
66
66
  ```
67
67
 
@@ -77,19 +77,7 @@ openclaw plugins install clawmatrix
77
77
  }
78
78
  ```
79
79
 
80
- **重要**:共享模型的节点必须开启 chatCompletions 端点:
81
-
82
- ```json
83
- {
84
- "gateway": {
85
- "http": {
86
- "endpoints": {
87
- "chatCompletions": { "enabled": true }
88
- }
89
- }
90
- }
91
- }
92
- ```
80
+ **重要**:共享模型的节点只需在 `models` 中声明 `{ id, provider }`,ClawMatrix 会自动从 OpenClaw 的 `models.providers` 读取对应的 `baseUrl` 和 `apiKey`,直接调用模型 API(不经过 OpenClaw Gateway 的 agent 系统)。
93
81
 
94
82
  消费远程模型的节点需要在 `models.providers` 中注册(以 nodeId 为 key,baseUrl 指向 `http://127.0.0.1:19001`),详见 [BOOTSTRAP.md](BOOTSTRAP.md)。
95
83
 
@@ -129,14 +117,14 @@ openclaw clawmatrix status
129
117
  | `listenPort` | number | `19000` | 入站 WS 端口 |
130
118
  | `peers` | array | `[]` | 要连接的 peer:`{ nodeId, url }` |
131
119
  | `agents` | array | `[]` | 本节点 Agent:`{ id, description, tags }` |
132
- | `models` | array | `[]` | 共享给集群的模型:`{ id, provider }` |
120
+ | `models` | array | `[]` | 共享给集群的模型:`{ id, provider }`(自动读取 OpenClaw provider 配置,可选 `baseUrl`/`apiKey` 覆盖)|
133
121
  | `proxyModels` | array | `[]` | 要消费的远程模型:`{ id, nodeId? }` |
134
122
  | `tags` | array | `[]` | 自由标签 |
135
123
  | `proxyPort` | number | `19001` | 本地模型代理端口 |
136
124
  | `handoffTimeout` | number | `600000` | Handoff 超时(ms) |
137
125
  | `toolProxy.enabled` | boolean | `false` | 允许远程工具执行 |
138
- | `toolProxy.allow` | array | `[]` | 允许的工具(`[]` = 全部) |
139
- | `toolProxy.deny` | array | `[]` | 禁止的工具(优先) |
126
+ | `toolProxy.allow` | array | `[]` | 允许的工具名,`["*"]` `[]` 表示全部。`exec`/`read`/`write`/`edit` 本地执行,其余走 Gateway |
127
+ | `toolProxy.deny` | array | `[]` | 禁止的工具(优先于 allow) |
140
128
 
141
129
  ## 架构
142
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,10 +29,7 @@
29
29
  "scripts": {
30
30
  "test": "bun test",
31
31
  "prepublishOnly": "bun test",
32
- "release": "bun scripts/release.ts",
33
- "release:minor": "bun scripts/release.ts minor",
34
- "release:major": "bun scripts/release.ts major",
35
- "release:dry": "bun scripts/release.ts --dry-run"
32
+ "release": "bunx bumpp"
36
33
  },
37
34
  "dependencies": {
38
35
  "ws": "^8.19.0",
package/src/auth.ts CHANGED
@@ -22,17 +22,21 @@ export async function computeHmac(
22
22
  return Buffer.from(sig).toString("hex");
23
23
  }
24
24
 
25
+ /** Constant-time string comparison. */
26
+ export function timingSafeEqual(a: string, b: string): boolean {
27
+ if (a.length !== b.length) return false;
28
+ let diff = 0;
29
+ for (let i = 0; i < a.length; i++) {
30
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
31
+ }
32
+ return diff === 0;
33
+ }
34
+
25
35
  export async function verifyHmac(
26
36
  nonce: string,
27
37
  secret: string,
28
38
  sig: string,
29
39
  ): Promise<boolean> {
30
40
  const expected = await computeHmac(nonce, secret);
31
- if (expected.length !== sig.length) return false;
32
- // Constant-time comparison
33
- let diff = 0;
34
- for (let i = 0; i < expected.length; i++) {
35
- diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
36
- }
37
- return diff === 0;
41
+ return timingSafeEqual(expected, sig);
38
42
  }
@@ -11,6 +11,7 @@ import { PeerManager } from "./peer-manager.ts";
11
11
  import { HandoffManager } from "./handoff.ts";
12
12
  import { ModelProxy } from "./model-proxy.ts";
13
13
  import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
14
+ import { WebHandler } from "./web.ts";
14
15
  import type {
15
16
  AnyClusterFrame,
16
17
  HandoffRequest,
@@ -51,7 +52,7 @@ export class ClusterRuntime {
51
52
  const gatewayInfo = resolveGatewayInfo(openclawConfig);
52
53
  this.peerManager = new PeerManager(config);
53
54
  this.handoffManager = new HandoffManager(config, this.peerManager);
54
- this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo);
55
+ this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
55
56
  this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
56
57
  }
57
58
 
@@ -69,6 +70,13 @@ export class ClusterRuntime {
69
70
  this.logger.info(`[clawmatrix] Peer disconnected: ${nodeId}`);
70
71
  });
71
72
 
73
+ // Web dashboard (must be set before peerManager.start() creates the HTTP server)
74
+ if (this.config.web?.enabled) {
75
+ const webHandler = new WebHandler(this.config, this.peerManager);
76
+ this.peerManager.setHttpHandler((req, res) => webHandler.handle(req, res));
77
+ this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
78
+ }
79
+
72
80
  // Start subsystems
73
81
  this.peerManager.start();
74
82
  this.modelProxy.start();
package/src/config.ts CHANGED
@@ -44,6 +44,8 @@ const ModelInfoSchema = z.object({
44
44
  id: z.string(),
45
45
  provider: z.string(),
46
46
  description: z.string().optional(),
47
+ baseUrl: z.string().optional(),
48
+ apiKey: z.string().optional(),
47
49
  ...ModelParamsSchema,
48
50
  });
49
51
 
@@ -66,6 +68,11 @@ const ProxyModelSchema = z.object({
66
68
  ...ModelParamsSchema,
67
69
  });
68
70
 
71
+ const WebConfigSchema = z.object({
72
+ enabled: z.boolean().default(false),
73
+ token: z.string().min(8, "web token must be at least 8 characters"),
74
+ }).optional();
75
+
69
76
  export const ClawMatrixConfigSchema = z.object({
70
77
  nodeId: z.string(),
71
78
  secret: z.string().min(16, "secret must be at least 16 characters"),
@@ -80,6 +87,7 @@ export const ClawMatrixConfigSchema = z.object({
80
87
  proxyPort: z.number().default(19001),
81
88
  toolProxy: ToolProxyConfigSchema.optional(),
82
89
  handoffTimeout: z.number().default(600_000),
90
+ web: WebConfigSchema,
83
91
  });
84
92
 
85
93
  export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
package/src/index.ts CHANGED
@@ -191,6 +191,12 @@ const plugin = {
191
191
  " - cluster_peers — inspect cluster topology",
192
192
  "Use the node's description and tags to decide which node to target. " +
193
193
  "For simple operations, prefer cluster_exec/read/write; for complex multi-step tasks, prefer cluster_handoff.",
194
+ "",
195
+ "IMPORTANT: Before calling any cluster tool or using a remote model, you MUST explicitly tell the user " +
196
+ "which remote node you are targeting and what you are about to do. For example:",
197
+ ' "I\'m going to run this command on remote node «coder» ..."',
198
+ ' "I\'m delegating this task to agent «reviewer» on node «server-b» ..."',
199
+ "This ensures the user always knows when operations leave the local node.",
194
200
  );
195
201
 
196
202
  return { prependContext: lines.join("\n") };
@@ -2,6 +2,7 @@ import { createServer, type Server } from "node:http";
2
2
  import type { PeerManager } from "./peer-manager.ts";
3
3
  import type { ClawMatrixConfig } from "./config.ts";
4
4
  import type { GatewayInfo } from "./tool-proxy.ts";
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
5
6
  import type {
6
7
  ModelRequest,
7
8
  ModelResponse,
@@ -26,11 +27,45 @@ export class ModelProxy {
26
27
  private pending = new Map<string, PendingModelReq>();
27
28
  private httpServer: Server | null = null;
28
29
  private gatewayInfo: GatewayInfo;
30
+ private openclawConfig: OpenClawConfig;
29
31
 
30
- constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
32
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo, openclawConfig: OpenClawConfig) {
31
33
  this.config = config;
32
34
  this.peerManager = peerManager;
33
35
  this.gatewayInfo = gatewayInfo;
36
+ this.openclawConfig = openclawConfig;
37
+ }
38
+
39
+ /** Resolve API endpoint for a model: explicit config > OpenClaw provider > gateway fallback */
40
+ private resolveModelEndpoint(model: { id: string; provider: string; baseUrl?: string; apiKey?: string }): { url: string; apiKey?: string; direct: boolean } {
41
+ // 1. Explicit baseUrl in ClawMatrix model config
42
+ if (model.baseUrl) {
43
+ return {
44
+ url: `${model.baseUrl.replace(/\/$/, "")}/chat/completions`,
45
+ apiKey: model.apiKey,
46
+ direct: true,
47
+ };
48
+ }
49
+
50
+ // 2. Read from OpenClaw's models.providers[provider]
51
+ const providers = (this.openclawConfig as Record<string, unknown>).models as
52
+ { providers?: Record<string, { baseUrl?: string; apiKey?: string }> } | undefined;
53
+ const providerConfig = providers?.providers?.[model.provider];
54
+ if (providerConfig?.baseUrl) {
55
+ return {
56
+ url: `${providerConfig.baseUrl.replace(/\/$/, "")}/chat/completions`,
57
+ apiKey: typeof providerConfig.apiKey === "string" ? providerConfig.apiKey : undefined,
58
+ direct: true,
59
+ };
60
+ }
61
+
62
+ // 3. Fallback: OpenClaw gateway (goes through agent system — not recommended)
63
+ const { port } = this.gatewayInfo;
64
+ return {
65
+ url: `http://127.0.0.1:${port}/v1/chat/completions`,
66
+ apiKey: undefined,
67
+ direct: false,
68
+ };
34
69
  }
35
70
 
36
71
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
@@ -450,7 +485,7 @@ export class ModelProxy {
450
485
  }
451
486
  }
452
487
 
453
- /** Handle model_req locally: forward to OpenClaw's configured model provider. */
488
+ /** Handle model_req locally: call the model API directly or fall back to OpenClaw gateway. */
454
489
  async handleModelRequest(frame: ModelRequest): Promise<void> {
455
490
  const { id, from, payload } = frame;
456
491
  debug("model_req", `handling model="${payload.model}" from=${from} stream=${payload.stream}`);
@@ -469,15 +504,23 @@ export class ModelProxy {
469
504
  }
470
505
 
471
506
  try {
472
- const { port, authHeader } = this.gatewayInfo;
507
+ const endpoint = this.resolveModelEndpoint(model);
473
508
  const headers: Record<string, string> = { "Content-Type": "application/json" };
474
- if (authHeader) headers["Authorization"] = authHeader;
475
509
 
476
- const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
510
+ if (endpoint.direct) {
511
+ if (endpoint.apiKey) headers["Authorization"] = `Bearer ${endpoint.apiKey}`;
512
+ debug("model_req", `direct API call to ${endpoint.url}`);
513
+ } else {
514
+ const { authHeader } = this.gatewayInfo;
515
+ if (authHeader) headers["Authorization"] = authHeader;
516
+ debug("model_req", `gateway fallback to ${endpoint.url} (not recommended)`);
517
+ }
518
+
519
+ const response = await fetch(endpoint.url, {
477
520
  method: "POST",
478
521
  headers,
479
522
  body: JSON.stringify({
480
- model: `${model.provider}/${model.id}`,
523
+ model: endpoint.direct ? model.id : `${model.provider}/${model.id}`,
481
524
  messages: payload.messages,
482
525
  temperature: payload.temperature,
483
526
  max_tokens: payload.maxTokens,
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { createServer, type IncomingMessage, type Server } from "node:http";
2
+ import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
3
3
  import { WebSocketServer, WebSocket as WsWebSocket } from "ws";
4
4
  import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
5
5
  import { Connection } from "./connection.ts";
@@ -95,12 +95,20 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
95
95
  this.router.destroy();
96
96
  }
97
97
 
98
+ /** Set an HTTP request handler for non-WebSocket requests (e.g. web dashboard). */
99
+ private httpRequestHandler: ((req: IncomingMessage, res: ServerResponse) => boolean) | null = null;
100
+
101
+ setHttpHandler(handler: (req: IncomingMessage, res: ServerResponse) => boolean) {
102
+ this.httpRequestHandler = handler;
103
+ }
104
+
98
105
  // ── Inbound WS server (node:http + ws) ──────────────────────────
99
106
  private startListening() {
100
107
  const port = this.config.listenPort;
101
108
  const hostname = this.config.listenHost;
102
109
 
103
- this.httpServer = createServer((_req, res) => {
110
+ this.httpServer = createServer((req, res) => {
111
+ if (this.httpRequestHandler && this.httpRequestHandler(req, res)) return;
104
112
  res.writeHead(426, { "Content-Type": "text/plain" });
105
113
  res.end("WebSocket upgrade required");
106
114
  });
@@ -270,7 +278,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
270
278
  // Validate from field: must be the direct peer or a known node (relayed)
271
279
  if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) return;
272
280
 
273
- if (frame.id && this.router.isDuplicate(frame.id)) return;
281
+ // Skip dedup for streaming frame types — they share one id across many chunks
282
+ const isStreamFrame = frame.type === "model_stream" || frame.type === "handoff_stream";
283
+ if (frame.id && !isStreamFrame && this.router.isDuplicate(frame.id)) return;
274
284
 
275
285
  if (frame.type === "peer_sync") {
276
286
  this.handlePeerSync(frame as PeerSync, from);