clawmatrix 0.1.15 → 0.1.16
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 +17 -2
- package/package.json +1 -1
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +31 -4
- package/src/compat.ts +3 -0
- package/src/connection.ts +15 -6
- package/src/handoff.ts +311 -17
- package/src/http-utils.ts +35 -0
- package/src/index.ts +33 -15
- package/src/model-proxy.ts +19 -16
- package/src/peer-manager.ts +55 -5
- package/src/router.ts +62 -28
- package/src/tool-proxy.ts +22 -7
- package/src/tools/cluster-events.ts +119 -0
- package/src/tools/cluster-exec.ts +4 -0
- package/src/tools/cluster-handoff-reply.ts +77 -0
- package/src/tools/cluster-handoff.ts +12 -0
- package/src/tools/cluster-peers.ts +17 -1
- package/src/tools/cluster-send.ts +1 -3
- package/src/tools/cluster-tool.ts +2 -5
- package/src/types.ts +93 -0
- package/src/web-ui.ts +490 -345
- package/src/web.ts +675 -53
package/BOOTSTRAP.md
CHANGED
|
@@ -175,12 +175,23 @@ ClawMatrix 的 Agent 工具注册为可选工具(optional),需要在 OpenC
|
|
|
175
175
|
```json
|
|
176
176
|
{
|
|
177
177
|
"tools": {
|
|
178
|
-
"
|
|
178
|
+
"profile": "full",
|
|
179
|
+
"sessions": {
|
|
180
|
+
"visibility": "all"
|
|
181
|
+
},
|
|
182
|
+
"allow": [
|
|
183
|
+
"clawmatrix"
|
|
184
|
+
]
|
|
179
185
|
}
|
|
180
186
|
}
|
|
181
187
|
```
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
配置说明:
|
|
190
|
+
- `profile: "full"` — 启用完整工具集(包括可选工具)
|
|
191
|
+
- `sessions.visibility: "all"` — 允许跨会话访问工具(集群工具需要此设置才能在所有会话中可用)
|
|
192
|
+
- `allow: ["clawmatrix"]` — 显式允许 ClawMatrix 插件注册的所有集群工具
|
|
193
|
+
|
|
194
|
+
也可以按 Agent 粒度启用:
|
|
184
195
|
|
|
185
196
|
```json
|
|
186
197
|
{
|
|
@@ -189,6 +200,10 @@ ClawMatrix 的 Agent 工具注册为可选工具(optional),需要在 OpenC
|
|
|
189
200
|
{
|
|
190
201
|
"id": "main",
|
|
191
202
|
"tools": {
|
|
203
|
+
"profile": "full",
|
|
204
|
+
"sessions": {
|
|
205
|
+
"visibility": "all"
|
|
206
|
+
},
|
|
192
207
|
"allow": ["clawmatrix"]
|
|
193
208
|
}
|
|
194
209
|
}
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -3,17 +3,40 @@ export function generateNonce(): string {
|
|
|
3
3
|
return Buffer.from(bytes).toString("hex");
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
/** Cache imported CryptoKey keyed by a hash of the secret (avoid storing plaintext). Max 8 entries. */
|
|
7
|
+
const KEY_CACHE_MAX = 8;
|
|
8
|
+
const keyCache = new Map<string, CryptoKey>();
|
|
9
|
+
|
|
10
|
+
function cacheKeyFor(secret: string): string {
|
|
11
|
+
const { createHash } = require("node:crypto");
|
|
12
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function getHmacKey(secret: string): Promise<CryptoKey> {
|
|
16
|
+
const cacheKey = cacheKeyFor(secret);
|
|
17
|
+
let key = keyCache.get(cacheKey);
|
|
18
|
+
if (key) return key;
|
|
19
|
+
key = await crypto.subtle.importKey(
|
|
11
20
|
"raw",
|
|
12
21
|
new TextEncoder().encode(secret),
|
|
13
22
|
{ name: "HMAC", hash: "SHA-256" },
|
|
14
23
|
false,
|
|
15
24
|
["sign"],
|
|
16
25
|
);
|
|
26
|
+
if (keyCache.size >= KEY_CACHE_MAX) {
|
|
27
|
+
// Evict oldest entry
|
|
28
|
+
const oldest = keyCache.keys().next().value!;
|
|
29
|
+
keyCache.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
keyCache.set(cacheKey, key);
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function computeHmac(
|
|
36
|
+
nonce: string,
|
|
37
|
+
secret: string,
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
const key = await getHmacKey(secret);
|
|
17
40
|
const sig = await crypto.subtle.sign(
|
|
18
41
|
"HMAC",
|
|
19
42
|
key,
|
|
@@ -22,14 +45,21 @@ export async function computeHmac(
|
|
|
22
45
|
return Buffer.from(sig).toString("hex");
|
|
23
46
|
}
|
|
24
47
|
|
|
25
|
-
/** Constant-time string comparison.
|
|
48
|
+
/** Constant-time string comparison. Uses native crypto.timingSafeEqual with
|
|
49
|
+
* SHA-256 pre-hash to normalize lengths (avoids length-leak from early return). */
|
|
26
50
|
export function timingSafeEqual(a: string, b: string): boolean {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
51
|
+
const nodeTimingSafeEqual = require("node:crypto").timingSafeEqual;
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
const bufA = encoder.encode(a);
|
|
54
|
+
const bufB = encoder.encode(b);
|
|
55
|
+
if (bufA.byteLength !== bufB.byteLength) {
|
|
56
|
+
// Hash both to fixed length so we still compare in constant time
|
|
57
|
+
const { createHash } = require("node:crypto");
|
|
58
|
+
const hashA = createHash("sha256").update(bufA).digest();
|
|
59
|
+
const hashB = createHash("sha256").update(bufB).digest();
|
|
60
|
+
return nodeTimingSafeEqual(hashA, hashB);
|
|
31
61
|
}
|
|
32
|
-
return
|
|
62
|
+
return nodeTimingSafeEqual(Buffer.from(bufA), Buffer.from(bufB));
|
|
33
63
|
}
|
|
34
64
|
|
|
35
65
|
export async function verifyHmac(
|
|
@@ -38,5 +68,5 @@ export async function verifyHmac(
|
|
|
38
68
|
sig: string,
|
|
39
69
|
): Promise<boolean> {
|
|
40
70
|
const expected = await computeHmac(nonce, secret);
|
|
41
|
-
return timingSafeEqual(expected, sig);
|
|
71
|
+
return timingSafeEqual(expected, sig); // timingSafeEqual is now sync
|
|
42
72
|
}
|
package/src/cluster-service.ts
CHANGED
|
@@ -17,6 +17,11 @@ import type {
|
|
|
17
17
|
HandoffRequest,
|
|
18
18
|
HandoffResponse,
|
|
19
19
|
HandoffStreamChunk,
|
|
20
|
+
HandoffCancel,
|
|
21
|
+
HandoffStatusQuery,
|
|
22
|
+
HandoffStatusResponse,
|
|
23
|
+
HandoffInputRequired,
|
|
24
|
+
HandoffInput,
|
|
20
25
|
ModelRequest,
|
|
21
26
|
ModelResponse,
|
|
22
27
|
ModelStreamChunk,
|
|
@@ -44,6 +49,7 @@ export class ClusterRuntime {
|
|
|
44
49
|
readonly handoffManager: HandoffManager;
|
|
45
50
|
readonly modelProxy: ModelProxy;
|
|
46
51
|
readonly toolProxy: ToolProxy;
|
|
52
|
+
webHandler: WebHandler | null = null;
|
|
47
53
|
private logger: PluginLogger;
|
|
48
54
|
|
|
49
55
|
constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
|
|
@@ -58,7 +64,7 @@ export class ClusterRuntime {
|
|
|
58
64
|
|
|
59
65
|
start() {
|
|
60
66
|
// Wire up frame dispatch
|
|
61
|
-
this.peerManager.on("frame", (frame
|
|
67
|
+
this.peerManager.on("frame", (frame) => {
|
|
62
68
|
this.dispatchFrame(frame);
|
|
63
69
|
});
|
|
64
70
|
|
|
@@ -72,8 +78,10 @@ export class ClusterRuntime {
|
|
|
72
78
|
|
|
73
79
|
// Web dashboard (must be set before peerManager.start() creates the HTTP server)
|
|
74
80
|
if (this.config.web?.enabled) {
|
|
75
|
-
|
|
76
|
-
this.peerManager.setHttpHandler((req, res) => webHandler
|
|
81
|
+
this.webHandler = new WebHandler(this.config, this.peerManager, this.handoffManager);
|
|
82
|
+
this.peerManager.setHttpHandler((req, res) => this.webHandler!.handle(req, res));
|
|
83
|
+
// Enable satellite tool routing through WebHandler
|
|
84
|
+
this.toolProxy.setSatelliteHandler(this.webHandler);
|
|
77
85
|
this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
|
|
78
86
|
}
|
|
79
87
|
|
|
@@ -89,6 +97,7 @@ export class ClusterRuntime {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
async stop() {
|
|
100
|
+
this.webHandler?.destroy();
|
|
92
101
|
this.handoffManager.destroy();
|
|
93
102
|
this.modelProxy.stop();
|
|
94
103
|
this.toolProxy.destroy();
|
|
@@ -98,7 +107,7 @@ export class ClusterRuntime {
|
|
|
98
107
|
|
|
99
108
|
private dispatchFrame(frame: AnyClusterFrame) {
|
|
100
109
|
if (frame.type.startsWith("model_")) {
|
|
101
|
-
debug("dispatch", `${frame.type} id=${frame.id} from=${
|
|
110
|
+
debug("dispatch", `${frame.type} id=${frame.id} from=${frame.from}`);
|
|
102
111
|
}
|
|
103
112
|
switch (frame.type) {
|
|
104
113
|
case "handoff_req":
|
|
@@ -112,6 +121,24 @@ export class ClusterRuntime {
|
|
|
112
121
|
case "handoff_res":
|
|
113
122
|
this.handoffManager.handleResponse(frame as HandoffResponse);
|
|
114
123
|
break;
|
|
124
|
+
case "handoff_cancel":
|
|
125
|
+
this.handoffManager.handleCancel(frame as HandoffCancel);
|
|
126
|
+
break;
|
|
127
|
+
case "handoff_status":
|
|
128
|
+
this.handoffManager.handleStatusQuery(frame as HandoffStatusQuery);
|
|
129
|
+
break;
|
|
130
|
+
case "handoff_input_required":
|
|
131
|
+
this.handoffManager.handleInputRequired(frame as HandoffInputRequired);
|
|
132
|
+
break;
|
|
133
|
+
case "handoff_input":
|
|
134
|
+
this.handoffManager.handleInput(frame as HandoffInput).catch((err) => {
|
|
135
|
+
this.logger.error(`[clawmatrix] Handoff input error: ${err}`);
|
|
136
|
+
});
|
|
137
|
+
break;
|
|
138
|
+
case "handoff_status_res":
|
|
139
|
+
// Status responses are handled by the requester via pending callbacks
|
|
140
|
+
// (currently informational — logged but not routed to pending map)
|
|
141
|
+
break;
|
|
115
142
|
case "model_req":
|
|
116
143
|
this.modelProxy.handleModelRequest(frame as ModelRequest).catch((err) => {
|
|
117
144
|
this.logger.error(`[clawmatrix] Model request error: ${err}`);
|
package/src/compat.ts
CHANGED
package/src/connection.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
DeviceInfo,
|
|
10
10
|
ModelInfo,
|
|
11
11
|
NodeCapabilities,
|
|
12
|
+
ToolProxyInfo,
|
|
12
13
|
} from "./types.ts";
|
|
13
14
|
import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
|
|
14
15
|
|
|
@@ -68,13 +69,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
68
69
|
this.secret = secret;
|
|
69
70
|
this.localCapabilities = localCapabilities;
|
|
70
71
|
|
|
72
|
+
// Both inbound and outbound get an auth timeout to prevent hanging connections
|
|
73
|
+
this.authTimer = setTimeout(() => {
|
|
74
|
+
if (!this.authenticated) {
|
|
75
|
+
this.close(4003, "auth timeout");
|
|
76
|
+
}
|
|
77
|
+
}, AUTH_TIMEOUT);
|
|
78
|
+
|
|
71
79
|
if (role === "inbound") {
|
|
72
80
|
this.sendAuthChallenge();
|
|
73
|
-
this.authTimer = setTimeout(() => {
|
|
74
|
-
if (!this.authenticated) {
|
|
75
|
-
this.close(4003, "auth timeout");
|
|
76
|
-
}
|
|
77
|
-
}, AUTH_TIMEOUT);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -162,13 +165,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
162
165
|
private async handleAuthMessage(frame: AnyClusterFrame) {
|
|
163
166
|
if (this.role === "inbound") {
|
|
164
167
|
if (frame.type !== "auth") return;
|
|
165
|
-
const { nodeId, sig, agents, models, tags, deviceInfo } = frame.payload as {
|
|
168
|
+
const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy } = frame.payload as {
|
|
166
169
|
nodeId: string;
|
|
167
170
|
sig: string;
|
|
168
171
|
agents?: AgentInfo[];
|
|
169
172
|
models?: ModelInfo[];
|
|
170
173
|
tags?: string[];
|
|
171
174
|
deviceInfo?: DeviceInfo;
|
|
175
|
+
toolProxy?: ToolProxyInfo;
|
|
172
176
|
};
|
|
173
177
|
if (!this.pendingNonce) return;
|
|
174
178
|
|
|
@@ -193,6 +197,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
193
197
|
models: models ?? [],
|
|
194
198
|
tags: tags ?? [],
|
|
195
199
|
deviceInfo,
|
|
200
|
+
toolProxy,
|
|
196
201
|
};
|
|
197
202
|
this.authenticated = true;
|
|
198
203
|
this.clearAuthTimer();
|
|
@@ -207,6 +212,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
207
212
|
models: this.localCapabilities.models,
|
|
208
213
|
tags: this.localCapabilities.tags,
|
|
209
214
|
deviceInfo: this.localCapabilities.deviceInfo,
|
|
215
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
210
216
|
},
|
|
211
217
|
} as AuthOk);
|
|
212
218
|
|
|
@@ -227,6 +233,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
227
233
|
models: this.localCapabilities.models,
|
|
228
234
|
tags: this.localCapabilities.tags,
|
|
229
235
|
deviceInfo: this.localCapabilities.deviceInfo,
|
|
236
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
230
237
|
},
|
|
231
238
|
});
|
|
232
239
|
return;
|
|
@@ -241,8 +248,10 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
241
248
|
models: ok.payload.models,
|
|
242
249
|
tags: ok.payload.tags,
|
|
243
250
|
deviceInfo: ok.payload.deviceInfo,
|
|
251
|
+
toolProxy: ok.payload.toolProxy,
|
|
244
252
|
};
|
|
245
253
|
this.authenticated = true;
|
|
254
|
+
this.clearAuthTimer();
|
|
246
255
|
this.startHeartbeat();
|
|
247
256
|
this.emit("authenticated", this.remoteCapabilities);
|
|
248
257
|
return;
|