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 +8 -27
- package/README.md +5 -17
- package/package.json +2 -5
- package/src/auth.ts +11 -7
- package/src/cluster-service.ts +9 -1
- package/src/config.ts +8 -0
- package/src/index.ts +6 -0
- package/src/model-proxy.ts +49 -6
- package/src/peer-manager.ts +13 -3
- package/src/web-ui.ts +1270 -0
- package/src/web.ts +230 -0
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": ["
|
|
96
|
-
"deny": [
|
|
95
|
+
"allow": ["*"],
|
|
96
|
+
"deny": []
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
注意事项:
|
|
102
|
-
- `models`
|
|
103
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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": ["
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
|
}
|
package/src/cluster-service.ts
CHANGED
|
@@ -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") };
|
package/src/model-proxy.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/peer-manager.ts
CHANGED
|
@@ -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((
|
|
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
|
-
|
|
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);
|