clawmatrix 0.1.14 → 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 +55 -8
- package/package.json +4 -2
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +35 -7
- package/src/compat.ts +3 -0
- package/src/config.ts +57 -6
- package/src/connection.ts +34 -8
- package/src/device-info.ts +48 -0
- package/src/handoff.ts +330 -21
- package/src/http-utils.ts +35 -0
- package/src/index.ts +47 -19
- package/src/model-proxy.ts +546 -242
- package/src/peer-manager.ts +65 -6
- package/src/router.ts +89 -47
- 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 +117 -0
- package/src/web-ui.ts +694 -342
- package/src/web.ts +726 -50
package/BOOTSTRAP.md
CHANGED
|
@@ -122,7 +122,8 @@ openclaw clawmatrix status
|
|
|
122
122
|
],
|
|
123
123
|
"models": [],
|
|
124
124
|
"proxyModels": [
|
|
125
|
-
{ "id": "
|
|
125
|
+
{ "id": "gpt-5", "nodeId": "office-01", "description": "GPT-5", "input": ["text", "image"], "contextWindow": 400000, "maxTokens": 128000 },
|
|
126
|
+
{ "id": "gemini-2.5-pro", "nodeId": "office-01", "description": "Gemini 2.5 Pro", "input": ["text", "image"], "contextWindow": 1048576, "maxTokens": 65535 }
|
|
126
127
|
],
|
|
127
128
|
"tags": ["home"]
|
|
128
129
|
}
|
|
@@ -165,9 +166,55 @@ openclaw clawmatrix status
|
|
|
165
166
|
| `toolProxy.deny` | array | `[]` | 禁止的工具名(优先于 allow) |
|
|
166
167
|
| `toolProxy.maxOutputBytes` | number | `1048576` | 单次响应最大字节数 |
|
|
167
168
|
|
|
168
|
-
##
|
|
169
|
+
## 启用集群工具
|
|
169
170
|
|
|
170
|
-
|
|
171
|
+
ClawMatrix 的 Agent 工具注册为可选工具(optional),需要在 OpenClaw 配置中显式启用。
|
|
172
|
+
|
|
173
|
+
在 `openclaw.json` 中添加:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"tools": {
|
|
178
|
+
"profile": "full",
|
|
179
|
+
"sessions": {
|
|
180
|
+
"visibility": "all"
|
|
181
|
+
},
|
|
182
|
+
"allow": [
|
|
183
|
+
"clawmatrix"
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
配置说明:
|
|
190
|
+
- `profile: "full"` — 启用完整工具集(包括可选工具)
|
|
191
|
+
- `sessions.visibility: "all"` — 允许跨会话访问工具(集群工具需要此设置才能在所有会话中可用)
|
|
192
|
+
- `allow: ["clawmatrix"]` — 显式允许 ClawMatrix 插件注册的所有集群工具
|
|
193
|
+
|
|
194
|
+
也可以按 Agent 粒度启用:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"agents": {
|
|
199
|
+
"list": [
|
|
200
|
+
{
|
|
201
|
+
"id": "main",
|
|
202
|
+
"tools": {
|
|
203
|
+
"profile": "full",
|
|
204
|
+
"sessions": {
|
|
205
|
+
"visibility": "all"
|
|
206
|
+
},
|
|
207
|
+
"allow": ["clawmatrix"]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
> 如果不配置,Agent 的系统提示中会提到集群工具,但实际无法调用。
|
|
216
|
+
|
|
217
|
+
启用后,本节点的 Agent 获得 7 个集群工具:
|
|
171
218
|
|
|
172
219
|
| 工具 | 用途 | 关键参数 |
|
|
173
220
|
|------|------|----------|
|
|
@@ -199,13 +246,13 @@ openclaw clawmatrix status
|
|
|
199
246
|
"api": "openai-completions",
|
|
200
247
|
"models": [
|
|
201
248
|
{
|
|
202
|
-
"id": "
|
|
203
|
-
"name": "
|
|
249
|
+
"id": "gpt-5",
|
|
250
|
+
"name": "GPT-5",
|
|
204
251
|
"reasoning": false,
|
|
205
|
-
"input": ["text"],
|
|
252
|
+
"input": ["text", "image"],
|
|
206
253
|
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
|
|
207
|
-
"contextWindow":
|
|
208
|
-
"maxTokens":
|
|
254
|
+
"contextWindow": 400000,
|
|
255
|
+
"maxTokens": 128000
|
|
209
256
|
}
|
|
210
257
|
]
|
|
211
258
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"@types/ws": "^8.18.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
+
"openclaw": ">=2026.3.7",
|
|
44
45
|
"typescript": "^5"
|
|
45
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"overrides": {}
|
|
46
48
|
}
|
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,13 +49,14 @@ 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
|
-
constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig) {
|
|
55
|
+
constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
|
|
50
56
|
this.config = config;
|
|
51
57
|
this.logger = logger;
|
|
52
58
|
const gatewayInfo = resolveGatewayInfo(openclawConfig);
|
|
53
|
-
this.peerManager = new PeerManager(config);
|
|
59
|
+
this.peerManager = new PeerManager(config, openclawVersion);
|
|
54
60
|
this.handoffManager = new HandoffManager(config, this.peerManager);
|
|
55
61
|
this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
|
|
56
62
|
this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
|
|
@@ -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}`);
|
|
@@ -178,11 +205,12 @@ export function getClusterRuntime(): ClusterRuntime {
|
|
|
178
205
|
export function createClusterService(
|
|
179
206
|
config: ClawMatrixConfig,
|
|
180
207
|
openclawConfig: OpenClawConfig,
|
|
208
|
+
openclawVersion?: string,
|
|
181
209
|
): OpenClawPluginService {
|
|
182
210
|
return {
|
|
183
211
|
id: "clawmatrix",
|
|
184
212
|
start(ctx: OpenClawPluginServiceContext) {
|
|
185
|
-
clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig);
|
|
213
|
+
clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig, openclawVersion);
|
|
186
214
|
clusterRuntime.start();
|
|
187
215
|
},
|
|
188
216
|
async stop() {
|
package/src/compat.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -61,19 +61,28 @@ const ToolProxyConfigSchema = z.object({
|
|
|
61
61
|
maxOutputBytes: z.number().default(1_048_576),
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Per-model entry inside a proxy group (minimal — inherits group-level fields)
|
|
65
|
+
const ProxyModelEntrySchema = z.object({
|
|
65
66
|
id: z.string(),
|
|
66
|
-
nodeId: z.string().optional(),
|
|
67
67
|
description: z.string().optional(),
|
|
68
68
|
...ModelParamsSchema,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
// Grouped proxy models: shared nodeId/api/provider, list of models
|
|
72
|
+
const ProxyModelGroupSchema = z.object({
|
|
73
|
+
nodeId: z.string(),
|
|
74
|
+
provider: z.string().optional(),
|
|
75
|
+
description: z.string().optional(),
|
|
76
|
+
...ModelParamsSchema,
|
|
77
|
+
models: z.array(ProxyModelEntrySchema),
|
|
78
|
+
});
|
|
79
|
+
|
|
71
80
|
const WebConfigSchema = z.object({
|
|
72
81
|
enabled: z.boolean().default(false),
|
|
73
82
|
token: z.string().min(8, "web token must be at least 8 characters"),
|
|
74
83
|
}).optional();
|
|
75
84
|
|
|
76
|
-
|
|
85
|
+
const RawClawMatrixConfigSchema = z.object({
|
|
77
86
|
nodeId: z.string(),
|
|
78
87
|
secret: z.string().min(16, "secret must be at least 16 characters"),
|
|
79
88
|
listen: z.boolean().default(false),
|
|
@@ -82,7 +91,7 @@ export const ClawMatrixConfigSchema = z.object({
|
|
|
82
91
|
peers: z.array(PeerConfigSchema).default([]),
|
|
83
92
|
agents: z.array(AgentInfoSchema).default([]),
|
|
84
93
|
models: z.array(ModelInfoSchema).default([]),
|
|
85
|
-
proxyModels: z.array(
|
|
94
|
+
proxyModels: z.array(ProxyModelGroupSchema).default([]),
|
|
86
95
|
tags: z.array(z.string()).default([]),
|
|
87
96
|
proxyPort: z.number().default(19001),
|
|
88
97
|
toolProxy: ToolProxyConfigSchema.optional(),
|
|
@@ -90,10 +99,52 @@ export const ClawMatrixConfigSchema = z.object({
|
|
|
90
99
|
web: WebConfigSchema,
|
|
91
100
|
});
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
/** Flat proxy model after group expansion (used internally). */
|
|
103
|
+
export interface ProxyModel {
|
|
104
|
+
id: string;
|
|
105
|
+
nodeId: string;
|
|
106
|
+
provider?: string;
|
|
107
|
+
description?: string;
|
|
108
|
+
api?: string;
|
|
109
|
+
contextWindow?: number;
|
|
110
|
+
maxTokens?: number;
|
|
111
|
+
reasoning?: boolean;
|
|
112
|
+
input?: ("text" | "image")[];
|
|
113
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
114
|
+
compat?: z.infer<typeof ModelCompatSchema>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels"> & {
|
|
118
|
+
proxyModels: ProxyModel[];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export { RawClawMatrixConfigSchema as ClawMatrixConfigSchema };
|
|
94
122
|
export type PeerConfig = z.infer<typeof PeerConfigSchema>;
|
|
95
123
|
export type ToolProxyConfig = z.infer<typeof ToolProxyConfigSchema>;
|
|
96
124
|
|
|
125
|
+
/** Parse and flatten grouped proxyModels into flat array. */
|
|
97
126
|
export function parseConfig(raw: unknown): ClawMatrixConfig {
|
|
98
|
-
|
|
127
|
+
const parsed = RawClawMatrixConfigSchema.parse(raw);
|
|
128
|
+
|
|
129
|
+
// Flatten proxy model groups
|
|
130
|
+
const proxyModels: ProxyModel[] = [];
|
|
131
|
+
for (const group of parsed.proxyModels) {
|
|
132
|
+
for (const m of group.models) {
|
|
133
|
+
proxyModels.push({
|
|
134
|
+
id: m.id,
|
|
135
|
+
nodeId: group.nodeId,
|
|
136
|
+
provider: group.provider,
|
|
137
|
+
description: m.description ?? group.description,
|
|
138
|
+
api: m.api ?? group.api,
|
|
139
|
+
contextWindow: m.contextWindow ?? group.contextWindow,
|
|
140
|
+
maxTokens: m.maxTokens ?? group.maxTokens,
|
|
141
|
+
reasoning: m.reasoning ?? group.reasoning,
|
|
142
|
+
input: m.input ?? group.input,
|
|
143
|
+
cost: m.cost ?? group.cost,
|
|
144
|
+
compat: m.compat ?? group.compat,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { ...parsed, proxyModels };
|
|
99
150
|
}
|
package/src/connection.ts
CHANGED
|
@@ -6,8 +6,10 @@ import type {
|
|
|
6
6
|
AuthOk,
|
|
7
7
|
AuthFail,
|
|
8
8
|
ClusterFrame,
|
|
9
|
+
DeviceInfo,
|
|
9
10
|
ModelInfo,
|
|
10
11
|
NodeCapabilities,
|
|
12
|
+
ToolProxyInfo,
|
|
11
13
|
} from "./types.ts";
|
|
12
14
|
import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
|
|
13
15
|
|
|
@@ -28,6 +30,7 @@ export interface WsTransport {
|
|
|
28
30
|
export interface ConnectionEvents {
|
|
29
31
|
message: [frame: AnyClusterFrame];
|
|
30
32
|
authenticated: [capabilities: NodeCapabilities];
|
|
33
|
+
latency: [ms: number];
|
|
31
34
|
close: [code: number, reason: string];
|
|
32
35
|
error: [error: Error];
|
|
33
36
|
}
|
|
@@ -48,6 +51,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
48
51
|
private missedPongs = 0;
|
|
49
52
|
private pendingNonce: string | null = null;
|
|
50
53
|
private closed = false;
|
|
54
|
+
private lastPingSentAt = 0;
|
|
55
|
+
/** Exponential moving average of heartbeat RTT in milliseconds. */
|
|
56
|
+
latencyMs = 0;
|
|
51
57
|
|
|
52
58
|
constructor(
|
|
53
59
|
transport: WsTransport,
|
|
@@ -63,13 +69,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
63
69
|
this.secret = secret;
|
|
64
70
|
this.localCapabilities = localCapabilities;
|
|
65
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
|
+
|
|
66
79
|
if (role === "inbound") {
|
|
67
80
|
this.sendAuthChallenge();
|
|
68
|
-
this.authTimer = setTimeout(() => {
|
|
69
|
-
if (!this.authenticated) {
|
|
70
|
-
this.close(4003, "auth timeout");
|
|
71
|
-
}
|
|
72
|
-
}, AUTH_TIMEOUT);
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
|
|
@@ -142,6 +150,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
142
150
|
}
|
|
143
151
|
if (frame.type === "pong") {
|
|
144
152
|
this.missedPongs = 0;
|
|
153
|
+
if (this.lastPingSentAt > 0) {
|
|
154
|
+
const rtt = Date.now() - this.lastPingSentAt;
|
|
155
|
+
// Exponential moving average (α = 0.3)
|
|
156
|
+
this.latencyMs = this.latencyMs === 0 ? rtt : Math.round(this.latencyMs * 0.7 + rtt * 0.3);
|
|
157
|
+
this.emit("latency", this.latencyMs);
|
|
158
|
+
}
|
|
145
159
|
return;
|
|
146
160
|
}
|
|
147
161
|
|
|
@@ -151,12 +165,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
151
165
|
private async handleAuthMessage(frame: AnyClusterFrame) {
|
|
152
166
|
if (this.role === "inbound") {
|
|
153
167
|
if (frame.type !== "auth") return;
|
|
154
|
-
const { nodeId, sig, agents, models, tags } = frame.payload as {
|
|
168
|
+
const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy } = frame.payload as {
|
|
155
169
|
nodeId: string;
|
|
156
170
|
sig: string;
|
|
157
171
|
agents?: AgentInfo[];
|
|
158
172
|
models?: ModelInfo[];
|
|
159
173
|
tags?: string[];
|
|
174
|
+
deviceInfo?: DeviceInfo;
|
|
175
|
+
toolProxy?: ToolProxyInfo;
|
|
160
176
|
};
|
|
161
177
|
if (!this.pendingNonce) return;
|
|
162
178
|
|
|
@@ -180,6 +196,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
180
196
|
agents: agents ?? [],
|
|
181
197
|
models: models ?? [],
|
|
182
198
|
tags: tags ?? [],
|
|
199
|
+
deviceInfo,
|
|
200
|
+
toolProxy,
|
|
183
201
|
};
|
|
184
202
|
this.authenticated = true;
|
|
185
203
|
this.clearAuthTimer();
|
|
@@ -193,8 +211,10 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
193
211
|
agents: this.localCapabilities.agents,
|
|
194
212
|
models: this.localCapabilities.models,
|
|
195
213
|
tags: this.localCapabilities.tags,
|
|
214
|
+
deviceInfo: this.localCapabilities.deviceInfo,
|
|
215
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
196
216
|
},
|
|
197
|
-
}
|
|
217
|
+
} as AuthOk);
|
|
198
218
|
|
|
199
219
|
this.startHeartbeat();
|
|
200
220
|
this.emit("authenticated", this.remoteCapabilities);
|
|
@@ -212,6 +232,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
212
232
|
agents: this.localCapabilities.agents,
|
|
213
233
|
models: this.localCapabilities.models,
|
|
214
234
|
tags: this.localCapabilities.tags,
|
|
235
|
+
deviceInfo: this.localCapabilities.deviceInfo,
|
|
236
|
+
toolProxy: this.localCapabilities.toolProxy,
|
|
215
237
|
},
|
|
216
238
|
});
|
|
217
239
|
return;
|
|
@@ -225,8 +247,11 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
225
247
|
agents: ok.payload.agents,
|
|
226
248
|
models: ok.payload.models,
|
|
227
249
|
tags: ok.payload.tags,
|
|
250
|
+
deviceInfo: ok.payload.deviceInfo,
|
|
251
|
+
toolProxy: ok.payload.toolProxy,
|
|
228
252
|
};
|
|
229
253
|
this.authenticated = true;
|
|
254
|
+
this.clearAuthTimer();
|
|
230
255
|
this.startHeartbeat();
|
|
231
256
|
this.emit("authenticated", this.remoteCapabilities);
|
|
232
257
|
return;
|
|
@@ -258,10 +283,11 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
258
283
|
this.close(4002, "heartbeat timeout");
|
|
259
284
|
return;
|
|
260
285
|
}
|
|
286
|
+
this.lastPingSentAt = Date.now();
|
|
261
287
|
this.sendRaw({
|
|
262
288
|
type: "ping",
|
|
263
289
|
from: this.nodeId,
|
|
264
|
-
timestamp:
|
|
290
|
+
timestamp: this.lastPingSentAt,
|
|
265
291
|
});
|
|
266
292
|
scheduleNext();
|
|
267
293
|
}, interval);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect local device/system information using Node.js os module.
|
|
3
|
+
*/
|
|
4
|
+
import { arch, cpus, hostname, totalmem, type, release } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import type { DeviceInfo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
function readVersionFromPkgJson(dir: string): string | undefined {
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(join(dir, "package.json"), "utf-8");
|
|
12
|
+
const pkg = JSON.parse(raw) as { name?: string; version?: string };
|
|
13
|
+
if (pkg.name === "openclaw" && pkg.version) return pkg.version;
|
|
14
|
+
} catch { /* not found */ }
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveOpenclawVersion(hint?: string): string | undefined {
|
|
19
|
+
if (hint && hint !== "unknown") return hint;
|
|
20
|
+
// Walk up from the OpenClaw entry script (process.argv[1]) to find package.json
|
|
21
|
+
try {
|
|
22
|
+
const entry = process.argv[1];
|
|
23
|
+
if (!entry) return undefined;
|
|
24
|
+
let dir = dirname(entry);
|
|
25
|
+
for (let i = 0; i < 10; i++) {
|
|
26
|
+
const v = readVersionFromPkgJson(dir);
|
|
27
|
+
if (v) return v;
|
|
28
|
+
const parent = dirname(dir);
|
|
29
|
+
if (parent === dir) break;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
} catch { /* best effort */ }
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Collect device info once at startup. */
|
|
37
|
+
export function collectDeviceInfo(openclawVersion?: string): DeviceInfo {
|
|
38
|
+
const cpuList = cpus();
|
|
39
|
+
return {
|
|
40
|
+
os: `${type()} ${release()}`,
|
|
41
|
+
arch: arch(),
|
|
42
|
+
cpuModel: cpuList[0]?.model?.trim() ?? "unknown",
|
|
43
|
+
cpuCores: cpuList.length,
|
|
44
|
+
totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
|
|
45
|
+
hostname: hostname(),
|
|
46
|
+
openclawVersion: resolveOpenclawVersion(openclawVersion),
|
|
47
|
+
};
|
|
48
|
+
}
|