clawmatrix 0.1.0 → 0.1.2
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/llms.txt +17 -21
- package/openclaw.plugin.json +12 -5
- package/package.json +3 -1
- package/src/cluster-service.ts +20 -5
- package/src/compat.ts +62 -0
- package/src/config.ts +2 -5
- package/src/handoff.ts +4 -3
- package/src/index.ts +4 -4
- package/src/model-proxy.ts +87 -54
- package/src/peer-manager.ts +42 -38
- package/src/tool-proxy.ts +50 -176
- package/src/tools/cluster-exec.ts +9 -5
- package/src/tools/cluster-read.ts +6 -3
- package/src/tools/cluster-tool.ts +63 -0
- package/src/tools/cluster-write.ts +7 -5
- package/src/types.ts +1 -1
- package/src/tools/cluster-ls.ts +0 -51
package/llms.txt
CHANGED
|
@@ -18,7 +18,7 @@ openclaw gateway restart
|
|
|
18
18
|
|
|
19
19
|
- **Model Proxy** — Use LLMs hosted on remote nodes as if they were local. A local HTTP proxy bridges cluster WebSocket to OpenAI-compatible API.
|
|
20
20
|
- **Task Handoff** — Delegate complex tasks to agents running on other nodes (e.g. hand off to an internal "coder" agent that has repo access).
|
|
21
|
-
- **Tool Proxy** —
|
|
21
|
+
- **Tool Proxy** — Invoke any OpenClaw tool (exec, read, write, edit, web_search, etc.) on remote nodes without delegating the entire task.
|
|
22
22
|
- **Gossip Discovery** — Nodes discover each other via gossip protocol; no central registry needed.
|
|
23
23
|
- **Auto Failover** — If a node goes down, requests route to backup nodes automatically.
|
|
24
24
|
|
|
@@ -63,10 +63,8 @@ Add to `plugins.entries.clawmatrix.config` in your `openclaw.json`:
|
|
|
63
63
|
"tags": ["office", "gpu"],
|
|
64
64
|
"toolProxy": {
|
|
65
65
|
"enabled": true,
|
|
66
|
-
"allow": ["exec", "read", "
|
|
67
|
-
"deny": ["write"]
|
|
68
|
-
"allowPaths": ["/repo", "/data"],
|
|
69
|
-
"denyPaths": ["/etc", "/root"]
|
|
66
|
+
"allow": ["exec", "read", "edit", "web_search"],
|
|
67
|
+
"deny": ["write", "browser", "sessions_spawn"]
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
70
|
```
|
|
@@ -122,16 +120,13 @@ To use cluster models, set your agent's model to a cluster-proxied model:
|
|
|
122
120
|
| Field | Type | Default | Description |
|
|
123
121
|
|-------|------|---------|-------------|
|
|
124
122
|
| `enabled` | boolean | `false` | Allow remote tool execution on this node |
|
|
125
|
-
| `allow` | array | `[]` | Allowed
|
|
126
|
-
| `deny` | array | `[]` | Denied
|
|
127
|
-
| `allowPaths` | array | `[]` | Allowed path prefixes for file operations |
|
|
128
|
-
| `denyPaths` | array | `[]` | Denied path prefixes |
|
|
129
|
-
| `execAllowlist` | array | `[]` | If non-empty, only these commands are allowed |
|
|
123
|
+
| `allow` | array | `[]` | Allowed OpenClaw tool names (empty = all allowed) |
|
|
124
|
+
| `deny` | array | `[]` | Denied OpenClaw tool names (takes precedence over allow) |
|
|
130
125
|
| `maxOutputBytes` | number | `1048576` | Max output size per tool response (1 MB) |
|
|
131
126
|
|
|
132
127
|
## Agent tools
|
|
133
128
|
|
|
134
|
-
ClawMatrix registers 7 tools available to agents:
|
|
129
|
+
ClawMatrix registers 7 tools available to agents (tool proxy tools correspond to OpenClaw's built-in tools):
|
|
135
130
|
|
|
136
131
|
### cluster_peers
|
|
137
132
|
List all reachable peers, their agents, and available models.
|
|
@@ -148,27 +143,28 @@ Send a one-way message to another agent (injected into their session).
|
|
|
148
143
|
- `message`: Message content
|
|
149
144
|
|
|
150
145
|
### cluster_exec
|
|
151
|
-
Execute a shell command on a remote node.
|
|
146
|
+
Execute a shell command on a remote node (invokes OpenClaw `exec` tool).
|
|
152
147
|
- `node`: Target nodeId or `"tags:<tag>"`
|
|
153
148
|
- `command`: Shell command
|
|
154
|
-
- `
|
|
155
|
-
- `timeout`: Optional timeout in
|
|
149
|
+
- `workdir`: Optional working directory
|
|
150
|
+
- `timeout`: Optional timeout in seconds (default 1800)
|
|
156
151
|
|
|
157
152
|
### cluster_read
|
|
158
|
-
Read a file from a remote node.
|
|
153
|
+
Read a file from a remote node (invokes OpenClaw `read` tool).
|
|
159
154
|
- `node`: Target nodeId or `"tags:<tag>"`
|
|
160
|
-
- `path`:
|
|
155
|
+
- `path`: File path
|
|
161
156
|
|
|
162
157
|
### cluster_write
|
|
163
|
-
Write content to a file on a remote node.
|
|
158
|
+
Write content to a file on a remote node (invokes OpenClaw `write` tool).
|
|
164
159
|
- `node`: Target nodeId or `"tags:<tag>"`
|
|
165
|
-
- `path`:
|
|
160
|
+
- `path`: File path
|
|
166
161
|
- `content`: File content
|
|
167
162
|
|
|
168
|
-
###
|
|
169
|
-
|
|
163
|
+
### cluster_tool
|
|
164
|
+
Invoke any OpenClaw tool on a remote node (exec, read, write, edit, web_search, web_fetch, browser, process, etc.).
|
|
170
165
|
- `node`: Target nodeId or `"tags:<tag>"`
|
|
171
|
-
- `
|
|
166
|
+
- `tool`: OpenClaw tool name
|
|
167
|
+
- `args`: Tool arguments (tool-specific)
|
|
172
168
|
|
|
173
169
|
## Verify cluster status
|
|
174
170
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -55,11 +55,18 @@
|
|
|
55
55
|
"type": "object",
|
|
56
56
|
"properties": {
|
|
57
57
|
"enabled": { "type": "boolean", "default": false },
|
|
58
|
-
"allow": {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
"allow": {
|
|
59
|
+
"type": "array",
|
|
60
|
+
"items": { "type": "string" },
|
|
61
|
+
"default": [],
|
|
62
|
+
"description": "Allowed OpenClaw tool names (e.g. exec, read, write, edit, web_search). Empty = all allowed."
|
|
63
|
+
},
|
|
64
|
+
"deny": {
|
|
65
|
+
"type": "array",
|
|
66
|
+
"items": { "type": "string" },
|
|
67
|
+
"default": [],
|
|
68
|
+
"description": "Denied OpenClaw tool names (takes precedence over allow)."
|
|
69
|
+
},
|
|
63
70
|
"maxOutputBytes": { "type": "number", "default": 1048576 }
|
|
64
71
|
}
|
|
65
72
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawmatrix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
"prepublishOnly": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"ws": "^8.19.0",
|
|
34
35
|
"zod": "^4.3.6"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/bun": "latest",
|
|
39
|
+
"@types/ws": "^8.18.1",
|
|
38
40
|
"openclaw": "^2026.3.2"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
package/src/cluster-service.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
OpenClawPluginService,
|
|
3
3
|
OpenClawPluginServiceContext,
|
|
4
|
+
OpenClawConfig,
|
|
4
5
|
PluginLogger,
|
|
5
6
|
} from "openclaw/plugin-sdk";
|
|
6
7
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
8
|
+
import { spawnProcess } from "./compat.ts";
|
|
7
9
|
import { PeerManager } from "./peer-manager.ts";
|
|
8
10
|
import { HandoffManager } from "./handoff.ts";
|
|
9
11
|
import { ModelProxy } from "./model-proxy.ts";
|
|
10
|
-
import { ToolProxy } from "./tool-proxy.ts";
|
|
12
|
+
import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
|
|
11
13
|
import type {
|
|
12
14
|
AnyClusterFrame,
|
|
13
15
|
HandoffRequest,
|
|
@@ -20,6 +22,18 @@ import type {
|
|
|
20
22
|
ToolProxyResponse,
|
|
21
23
|
} from "./types.ts";
|
|
22
24
|
|
|
25
|
+
function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
|
|
26
|
+
const port = openclawConfig.gateway?.port ?? 18789;
|
|
27
|
+
const auth = openclawConfig.gateway?.auth;
|
|
28
|
+
let authHeader: string | undefined;
|
|
29
|
+
if (auth?.token) {
|
|
30
|
+
authHeader = `Bearer ${auth.token}`;
|
|
31
|
+
} else if (auth?.password && typeof auth.password === "string") {
|
|
32
|
+
authHeader = `Bearer ${auth.password}`;
|
|
33
|
+
}
|
|
34
|
+
return { port, authHeader };
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
/** Singleton cluster state shared across plugin components. */
|
|
24
38
|
export class ClusterRuntime {
|
|
25
39
|
readonly config: ClawMatrixConfig;
|
|
@@ -29,13 +43,13 @@ export class ClusterRuntime {
|
|
|
29
43
|
readonly toolProxy: ToolProxy;
|
|
30
44
|
private logger: PluginLogger;
|
|
31
45
|
|
|
32
|
-
constructor(config: ClawMatrixConfig, logger: PluginLogger) {
|
|
46
|
+
constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig) {
|
|
33
47
|
this.config = config;
|
|
34
48
|
this.logger = logger;
|
|
35
49
|
this.peerManager = new PeerManager(config);
|
|
36
50
|
this.handoffManager = new HandoffManager(config, this.peerManager);
|
|
37
51
|
this.modelProxy = new ModelProxy(config, this.peerManager);
|
|
38
|
-
this.toolProxy = new ToolProxy(config, this.peerManager);
|
|
52
|
+
this.toolProxy = new ToolProxy(config, this.peerManager, resolveGatewayInfo(openclawConfig));
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
start() {
|
|
@@ -124,7 +138,7 @@ export class ClusterRuntime {
|
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
// Fire-and-forget: inject message via openclaw agent
|
|
127
|
-
|
|
141
|
+
spawnProcess(["openclaw", "agent", "--message", message], {
|
|
128
142
|
stdout: "ignore",
|
|
129
143
|
stderr: "ignore",
|
|
130
144
|
});
|
|
@@ -143,11 +157,12 @@ export function getClusterRuntime(): ClusterRuntime {
|
|
|
143
157
|
|
|
144
158
|
export function createClusterService(
|
|
145
159
|
config: ClawMatrixConfig,
|
|
160
|
+
openclawConfig: OpenClawConfig,
|
|
146
161
|
): OpenClawPluginService {
|
|
147
162
|
return {
|
|
148
163
|
id: "clawmatrix",
|
|
149
164
|
start(ctx: OpenClawPluginServiceContext) {
|
|
150
|
-
clusterRuntime = new ClusterRuntime(config, ctx.logger);
|
|
165
|
+
clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig);
|
|
151
166
|
clusterRuntime.start();
|
|
152
167
|
},
|
|
153
168
|
async stop() {
|
package/src/compat.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime compatibility layer — provides Node.js-compatible alternatives
|
|
3
|
+
* for Bun-specific APIs so the plugin works inside the OpenClaw gateway
|
|
4
|
+
* (which runs on Node.js).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn as cpSpawn } from "node:child_process";
|
|
8
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
|
|
10
|
+
export interface SpawnResult {
|
|
11
|
+
exitCode: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Spawn a subprocess and collect stdout/stderr. */
|
|
17
|
+
export function spawnProcess(
|
|
18
|
+
cmd: string[],
|
|
19
|
+
opts?: { cwd?: string; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore" },
|
|
20
|
+
): { exited: Promise<number>; stdout: ReadableStream | null; stderr: ReadableStream | null; kill: () => void } {
|
|
21
|
+
const child = cpSpawn(cmd[0]!, cmd.slice(1), {
|
|
22
|
+
cwd: opts?.cwd,
|
|
23
|
+
stdio: [
|
|
24
|
+
"ignore",
|
|
25
|
+
opts?.stdout === "ignore" ? "ignore" : "pipe",
|
|
26
|
+
opts?.stderr === "ignore" ? "ignore" : "pipe",
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const exited = new Promise<number>((resolve, reject) => {
|
|
31
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
32
|
+
child.on("error", reject);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function nodeStreamToWeb(stream: import("node:stream").Readable | null): ReadableStream | null {
|
|
36
|
+
if (!stream) return null;
|
|
37
|
+
return new ReadableStream({
|
|
38
|
+
start(controller) {
|
|
39
|
+
stream.on("data", (chunk: Buffer) => controller.enqueue(chunk));
|
|
40
|
+
stream.on("end", () => controller.close());
|
|
41
|
+
stream.on("error", (err) => controller.error(err));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
exited,
|
|
48
|
+
stdout: nodeStreamToWeb(child.stdout),
|
|
49
|
+
stderr: nodeStreamToWeb(child.stderr),
|
|
50
|
+
kill: () => child.kill(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read a file as text (replaces Bun.file().text()). */
|
|
55
|
+
export async function readFileText(path: string): Promise<string> {
|
|
56
|
+
return readFile(path, "utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Write text to a file (replaces Bun.write()). */
|
|
60
|
+
export async function writeFileText(path: string, content: string): Promise<void> {
|
|
61
|
+
await writeFile(path, content, "utf-8");
|
|
62
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -19,11 +19,8 @@ const PeerConfigSchema = z.object({
|
|
|
19
19
|
|
|
20
20
|
const ToolProxyConfigSchema = z.object({
|
|
21
21
|
enabled: z.boolean().default(false),
|
|
22
|
-
allow: z.array(z.
|
|
23
|
-
deny: z.array(z.
|
|
24
|
-
allowPaths: z.array(z.string()).default([]),
|
|
25
|
-
denyPaths: z.array(z.string()).default([]),
|
|
26
|
-
execAllowlist: z.array(z.string()).default([]),
|
|
22
|
+
allow: z.array(z.string()).default([]),
|
|
23
|
+
deny: z.array(z.string()).default([]),
|
|
27
24
|
maxOutputBytes: z.number().default(1_048_576),
|
|
28
25
|
});
|
|
29
26
|
|
package/src/handoff.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PeerManager } from "./peer-manager.ts";
|
|
2
2
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
3
|
+
import { spawnProcess } from "./compat.ts";
|
|
3
4
|
import type {
|
|
4
5
|
HandoffRequest,
|
|
5
6
|
HandoffResponse,
|
|
@@ -147,16 +148,16 @@ export class HandoffManager {
|
|
|
147
148
|
? `${payload.task}\n\nContext:\n${payload.context}`
|
|
148
149
|
: payload.task;
|
|
149
150
|
|
|
150
|
-
const proc =
|
|
151
|
+
const proc = spawnProcess(["openclaw", "agent", "--message", message], {
|
|
151
152
|
stdout: "pipe",
|
|
152
153
|
stderr: "pipe",
|
|
153
154
|
});
|
|
154
155
|
|
|
155
|
-
const stdout = await new Response(proc.stdout).text();
|
|
156
|
+
const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
|
|
156
157
|
const exitCode = await proc.exited;
|
|
157
158
|
|
|
158
159
|
if (exitCode !== 0) {
|
|
159
|
-
const stderr = await new Response(proc.stderr).text();
|
|
160
|
+
const stderr = proc.stderr ? await new Response(proc.stderr).text() : "";
|
|
160
161
|
throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
|
|
161
162
|
}
|
|
162
163
|
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { createClusterPeersTool } from "./tools/cluster-peers.ts";
|
|
|
7
7
|
import { createClusterExecTool } from "./tools/cluster-exec.ts";
|
|
8
8
|
import { createClusterReadTool } from "./tools/cluster-read.ts";
|
|
9
9
|
import { createClusterWriteTool } from "./tools/cluster-write.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { createClusterToolTool } from "./tools/cluster-tool.ts";
|
|
11
11
|
import { registerClusterCli } from "./cli.ts";
|
|
12
12
|
|
|
13
13
|
const plugin = {
|
|
@@ -39,7 +39,7 @@ const plugin = {
|
|
|
39
39
|
const config = parseConfig(api.pluginConfig);
|
|
40
40
|
|
|
41
41
|
// Background service: manages mesh connections, WS listener, heartbeat
|
|
42
|
-
api.registerService(createClusterService(config));
|
|
42
|
+
api.registerService(createClusterService(config, api.config));
|
|
43
43
|
|
|
44
44
|
// Model provider: register clawmatrix as a provider pointing to local HTTP proxy
|
|
45
45
|
api.registerProvider({
|
|
@@ -61,7 +61,7 @@ const plugin = {
|
|
|
61
61
|
api.registerTool(createClusterExecTool(), { optional: true });
|
|
62
62
|
api.registerTool(createClusterReadTool(), { optional: true });
|
|
63
63
|
api.registerTool(createClusterWriteTool(), { optional: true });
|
|
64
|
-
api.registerTool(
|
|
64
|
+
api.registerTool(createClusterToolTool(), { optional: true });
|
|
65
65
|
|
|
66
66
|
// CLI subcommand
|
|
67
67
|
api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
|
|
@@ -88,7 +88,7 @@ const plugin = {
|
|
|
88
88
|
lines.push(
|
|
89
89
|
"",
|
|
90
90
|
"Available cluster tools: cluster_handoff, cluster_send, cluster_peers, " +
|
|
91
|
-
"cluster_exec, cluster_read, cluster_write,
|
|
91
|
+
"cluster_exec, cluster_read, cluster_write, cluster_tool",
|
|
92
92
|
);
|
|
93
93
|
|
|
94
94
|
return { prependContext: lines.join("\n") };
|
package/src/model-proxy.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
1
2
|
import type { PeerManager } from "./peer-manager.ts";
|
|
2
3
|
import type { ClawMatrixConfig } from "./config.ts";
|
|
3
4
|
import type {
|
|
@@ -21,7 +22,7 @@ export class ModelProxy {
|
|
|
21
22
|
private config: ClawMatrixConfig;
|
|
22
23
|
private peerManager: PeerManager;
|
|
23
24
|
private pending = new Map<string, PendingModelReq>();
|
|
24
|
-
private httpServer:
|
|
25
|
+
private httpServer: Server | null = null;
|
|
25
26
|
|
|
26
27
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
27
28
|
this.config = config;
|
|
@@ -30,26 +31,28 @@ export class ModelProxy {
|
|
|
30
31
|
|
|
31
32
|
/** Start the local HTTP proxy server for OpenAI-compatible requests. */
|
|
32
33
|
start() {
|
|
33
|
-
this.httpServer =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
34
|
+
this.httpServer = createServer(async (req, res) => {
|
|
35
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
36
|
+
|
|
37
|
+
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
|
|
38
|
+
const body = await this.readBody(req);
|
|
39
|
+
const response = await this.handleChatCompletion(body);
|
|
40
|
+
this.sendResponse(res, response);
|
|
41
|
+
} else if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
42
|
+
const response = this.handleListModels();
|
|
43
|
+
this.sendResponse(res, response);
|
|
44
|
+
} else {
|
|
45
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
46
|
+
res.end("Not Found");
|
|
47
|
+
}
|
|
47
48
|
});
|
|
49
|
+
|
|
50
|
+
this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
stop() {
|
|
51
54
|
if (this.httpServer) {
|
|
52
|
-
this.httpServer.
|
|
55
|
+
this.httpServer.close();
|
|
53
56
|
this.httpServer = null;
|
|
54
57
|
}
|
|
55
58
|
for (const [, pending] of this.pending) {
|
|
@@ -59,8 +62,38 @@ export class ModelProxy {
|
|
|
59
62
|
this.pending.clear();
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
private readBody(req: import("node:http").IncomingMessage): Promise<string> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const chunks: Buffer[] = [];
|
|
68
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
69
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private sendResponse(res: import("node:http").ServerResponse, response: { status: number; headers: Record<string, string>; body: string | ReadableStream }) {
|
|
75
|
+
res.writeHead(response.status, response.headers);
|
|
76
|
+
if (typeof response.body === "string") {
|
|
77
|
+
res.end(response.body);
|
|
78
|
+
} else {
|
|
79
|
+
// Stream response
|
|
80
|
+
const reader = response.body.getReader();
|
|
81
|
+
const pump = (): void => {
|
|
82
|
+
reader.read().then(({ done, value }) => {
|
|
83
|
+
if (done) {
|
|
84
|
+
res.end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
res.write(value);
|
|
88
|
+
pump();
|
|
89
|
+
}).catch(() => res.end());
|
|
90
|
+
};
|
|
91
|
+
pump();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
62
95
|
// ── HTTP handlers ──────────────────────────────────────────────
|
|
63
|
-
private async handleChatCompletion(
|
|
96
|
+
private async handleChatCompletion(rawBody: string): Promise<{ status: number; headers: Record<string, string>; body: string | ReadableStream }> {
|
|
64
97
|
let body: {
|
|
65
98
|
model: string;
|
|
66
99
|
messages: unknown[];
|
|
@@ -70,21 +103,23 @@ export class ModelProxy {
|
|
|
70
103
|
};
|
|
71
104
|
|
|
72
105
|
try {
|
|
73
|
-
body =
|
|
106
|
+
body = JSON.parse(rawBody);
|
|
74
107
|
} catch {
|
|
75
|
-
return
|
|
108
|
+
return {
|
|
76
109
|
status: 400,
|
|
77
110
|
headers: { "Content-Type": "application/json" },
|
|
78
|
-
|
|
111
|
+
body: JSON.stringify({ error: "Invalid JSON" }),
|
|
112
|
+
};
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
const modelId = body.model;
|
|
82
116
|
const route = this.peerManager.router.resolveModel(modelId);
|
|
83
117
|
if (!route) {
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
return {
|
|
119
|
+
status: 404,
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
|
|
122
|
+
};
|
|
88
123
|
}
|
|
89
124
|
|
|
90
125
|
const stream = body.stream ?? false;
|
|
@@ -116,7 +151,7 @@ export class ModelProxy {
|
|
|
116
151
|
requestId: string,
|
|
117
152
|
targetNodeId: string,
|
|
118
153
|
frame: ModelRequest,
|
|
119
|
-
):
|
|
154
|
+
): { status: number; headers: Record<string, string>; body: ReadableStream } {
|
|
120
155
|
const encoder = new TextEncoder();
|
|
121
156
|
|
|
122
157
|
const readable = new ReadableStream({
|
|
@@ -159,20 +194,22 @@ export class ModelProxy {
|
|
|
159
194
|
},
|
|
160
195
|
});
|
|
161
196
|
|
|
162
|
-
return
|
|
197
|
+
return {
|
|
198
|
+
status: 200,
|
|
163
199
|
headers: {
|
|
164
200
|
"Content-Type": "text/event-stream",
|
|
165
201
|
"Cache-Control": "no-cache",
|
|
166
|
-
Connection: "keep-alive",
|
|
202
|
+
"Connection": "keep-alive",
|
|
167
203
|
},
|
|
168
|
-
|
|
204
|
+
body: readable,
|
|
205
|
+
};
|
|
169
206
|
}
|
|
170
207
|
|
|
171
208
|
private async handleNonStreamRequest(
|
|
172
209
|
requestId: string,
|
|
173
210
|
targetNodeId: string,
|
|
174
211
|
frame: ModelRequest,
|
|
175
|
-
): Promise<
|
|
212
|
+
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
|
176
213
|
try {
|
|
177
214
|
const result = await new Promise<ModelResponse["payload"]>(
|
|
178
215
|
(resolve, reject) => {
|
|
@@ -199,15 +236,17 @@ export class ModelProxy {
|
|
|
199
236
|
);
|
|
200
237
|
|
|
201
238
|
if (!result.success) {
|
|
202
|
-
return
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
return {
|
|
240
|
+
status: 502,
|
|
241
|
+
headers: { "Content-Type": "application/json" },
|
|
242
|
+
body: JSON.stringify({ error: { message: result.error } }),
|
|
243
|
+
};
|
|
206
244
|
}
|
|
207
245
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
246
|
+
return {
|
|
247
|
+
status: 200,
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
body: JSON.stringify({
|
|
211
250
|
id: `chatcmpl-${requestId}`,
|
|
212
251
|
object: "chat.completion",
|
|
213
252
|
created: Math.floor(Date.now() / 1000),
|
|
@@ -227,17 +266,17 @@ export class ModelProxy {
|
|
|
227
266
|
}
|
|
228
267
|
: undefined,
|
|
229
268
|
}),
|
|
230
|
-
|
|
231
|
-
);
|
|
269
|
+
};
|
|
232
270
|
} catch (err) {
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
271
|
+
return {
|
|
272
|
+
status: 502,
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({ error: { message: err instanceof Error ? err.message : String(err) } }),
|
|
275
|
+
};
|
|
237
276
|
}
|
|
238
277
|
}
|
|
239
278
|
|
|
240
|
-
private handleListModels():
|
|
279
|
+
private handleListModels(): { status: number; headers: Record<string, string>; body: string } {
|
|
241
280
|
const models = this.peerManager.router
|
|
242
281
|
.getAllPeers()
|
|
243
282
|
.flatMap((p) =>
|
|
@@ -249,10 +288,11 @@ export class ModelProxy {
|
|
|
249
288
|
})),
|
|
250
289
|
);
|
|
251
290
|
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
291
|
+
return {
|
|
292
|
+
status: 200,
|
|
293
|
+
headers: { "Content-Type": "application/json" },
|
|
294
|
+
body: JSON.stringify({ object: "list", data: models }),
|
|
295
|
+
};
|
|
256
296
|
}
|
|
257
297
|
|
|
258
298
|
// ── Incoming frame handlers ────────────────────────────────────
|
|
@@ -273,7 +313,6 @@ export class ModelProxy {
|
|
|
273
313
|
|
|
274
314
|
try {
|
|
275
315
|
if (frame.payload.done) {
|
|
276
|
-
// Send final chunk with usage if available
|
|
277
316
|
if (frame.payload.usage) {
|
|
278
317
|
const usageChunk = {
|
|
279
318
|
id: `chatcmpl-${frame.id}`,
|
|
@@ -310,7 +349,6 @@ export class ModelProxy {
|
|
|
310
349
|
);
|
|
311
350
|
}
|
|
312
351
|
} catch {
|
|
313
|
-
// controller may be closed
|
|
314
352
|
clearTimeout(pending.timer);
|
|
315
353
|
this.pending.delete(frame.id);
|
|
316
354
|
}
|
|
@@ -320,7 +358,6 @@ export class ModelProxy {
|
|
|
320
358
|
async handleModelRequest(frame: ModelRequest): Promise<void> {
|
|
321
359
|
const { id, from, payload } = frame;
|
|
322
360
|
|
|
323
|
-
// Find the model in our local config
|
|
324
361
|
const model = this.config.models.find((m) => m.id === payload.model);
|
|
325
362
|
if (!model) {
|
|
326
363
|
this.peerManager.sendTo(from, {
|
|
@@ -335,9 +372,6 @@ export class ModelProxy {
|
|
|
335
372
|
}
|
|
336
373
|
|
|
337
374
|
try {
|
|
338
|
-
// Use OpenClaw's API to run the model locally
|
|
339
|
-
// This is done by calling the local gateway's /v1/chat/completions endpoint
|
|
340
|
-
// The local OpenClaw gateway handles provider routing
|
|
341
375
|
const localUrl = `http://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT ?? 3000}/v1/chat/completions`;
|
|
342
376
|
|
|
343
377
|
const response = await fetch(localUrl, {
|
|
@@ -353,7 +387,6 @@ export class ModelProxy {
|
|
|
353
387
|
});
|
|
354
388
|
|
|
355
389
|
if (payload.stream) {
|
|
356
|
-
// Read SSE stream and forward as model_stream frames
|
|
357
390
|
const reader = response.body?.getReader();
|
|
358
391
|
if (!reader) throw new Error("No response body");
|
|
359
392
|
|
package/src/peer-manager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import type
|
|
2
|
+
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
3
|
+
import { WebSocketServer, WebSocket as WsWebSocket } from "ws";
|
|
3
4
|
import type { ClawMatrixConfig, PeerConfig } from "./config.ts";
|
|
4
5
|
import { Connection } from "./connection.ts";
|
|
5
6
|
import type { WsTransport } from "./connection.ts";
|
|
@@ -25,12 +26,13 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
25
26
|
readonly router: Router;
|
|
26
27
|
private config: ClawMatrixConfig;
|
|
27
28
|
private localCapabilities: NodeCapabilities;
|
|
28
|
-
private
|
|
29
|
+
private httpServer: Server | null = null;
|
|
30
|
+
private wss: WebSocketServer | null = null;
|
|
29
31
|
private reconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
30
32
|
private reconnectAttempts = new Map<string, number>();
|
|
31
33
|
private stopped = false;
|
|
32
|
-
/** Map from
|
|
33
|
-
private inboundConnections = new Map<
|
|
34
|
+
/** Map from ws WebSocket to Connection for inbound connections. */
|
|
35
|
+
private inboundConnections = new Map<WsWebSocket, Connection>();
|
|
34
36
|
|
|
35
37
|
constructor(config: ClawMatrixConfig) {
|
|
36
38
|
super();
|
|
@@ -76,50 +78,52 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
76
78
|
conn.close(1000, "shutdown");
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
if (this.
|
|
80
|
-
this.
|
|
81
|
-
this.
|
|
81
|
+
if (this.wss) {
|
|
82
|
+
this.wss.close();
|
|
83
|
+
this.wss = null;
|
|
84
|
+
}
|
|
85
|
+
if (this.httpServer) {
|
|
86
|
+
this.httpServer.close();
|
|
87
|
+
this.httpServer = null;
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
// ── Inbound WS server (
|
|
91
|
+
// ── Inbound WS server (node:http + ws) ──────────────────────────
|
|
86
92
|
private startListening() {
|
|
87
93
|
const port = this.config.listenPort;
|
|
88
94
|
const hostname = this.config.listenHost;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
|
|
96
|
+
this.httpServer = createServer((_req, res) => {
|
|
97
|
+
res.writeHead(426, { "Content-Type": "text/plain" });
|
|
98
|
+
res.end("WebSocket upgrade required");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
102
|
+
|
|
103
|
+
this.wss.on("connection", (ws) => {
|
|
104
|
+
this.handleInboundOpen(ws);
|
|
105
|
+
|
|
106
|
+
ws.on("message", (data) => {
|
|
107
|
+
const conn = this.inboundConnections.get(ws);
|
|
108
|
+
if (conn) {
|
|
109
|
+
conn.feedMessage(typeof data === "string" ? data : String(data));
|
|
97
110
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
conn.feedMessage(typeof message === "string" ? message : Buffer.from(message));
|
|
108
|
-
}
|
|
109
|
-
},
|
|
110
|
-
close(ws, code, reason) {
|
|
111
|
-
const conn = self.inboundConnections.get(ws);
|
|
112
|
-
if (conn) {
|
|
113
|
-
conn.feedClose(code, reason);
|
|
114
|
-
self.inboundConnections.delete(ws);
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
ws.on("close", (code, reason) => {
|
|
114
|
+
const conn = this.inboundConnections.get(ws);
|
|
115
|
+
if (conn) {
|
|
116
|
+
conn.feedClose(code, reason.toString());
|
|
117
|
+
this.inboundConnections.delete(ws);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
118
120
|
});
|
|
121
|
+
|
|
122
|
+
this.httpServer.listen(port, hostname);
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
private handleInboundOpen(ws:
|
|
122
|
-
// Wrap
|
|
125
|
+
private handleInboundOpen(ws: WsWebSocket) {
|
|
126
|
+
// Wrap ws WebSocket into our WsTransport interface
|
|
123
127
|
const transport: WsTransport = {
|
|
124
128
|
send(data: string) {
|
|
125
129
|
ws.send(data);
|
package/src/tool-proxy.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { join, resolve, normalize } from "node:path";
|
|
3
1
|
import type { PeerManager } from "./peer-manager.ts";
|
|
4
2
|
import type { ClawMatrixConfig, ToolProxyConfig } from "./config.ts";
|
|
5
3
|
import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
|
|
@@ -12,57 +10,38 @@ interface PendingToolReq {
|
|
|
12
10
|
timer: ReturnType<typeof setTimeout>;
|
|
13
11
|
}
|
|
14
12
|
|
|
13
|
+
export interface GatewayInfo {
|
|
14
|
+
port: number;
|
|
15
|
+
authHeader?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
15
18
|
export class ToolProxy {
|
|
16
19
|
private config: ClawMatrixConfig;
|
|
17
20
|
private peerManager: PeerManager;
|
|
18
21
|
private pending = new Map<string, PendingToolReq>();
|
|
22
|
+
private gatewayInfo: GatewayInfo;
|
|
19
23
|
|
|
20
|
-
constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
|
|
24
|
+
constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
|
|
21
25
|
this.config = config;
|
|
22
26
|
this.peerManager = peerManager;
|
|
27
|
+
this.gatewayInfo = gatewayInfo;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
// ── Outbound: request remote execution ─────────────────────────
|
|
26
|
-
async
|
|
31
|
+
async invoke(
|
|
27
32
|
node: string,
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
tool: string,
|
|
34
|
+
params: Record<string, unknown>,
|
|
30
35
|
timeout?: number,
|
|
31
|
-
): Promise<
|
|
32
|
-
|
|
33
|
-
command,
|
|
34
|
-
cwd,
|
|
35
|
-
timeout: timeout ?? TOOL_TIMEOUT,
|
|
36
|
-
});
|
|
37
|
-
return result as { exitCode: number; stdout: string; stderr: string };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async read(node: string, path: string): Promise<{ content: string }> {
|
|
41
|
-
const result = await this.sendToolReq(node, "read", { path });
|
|
42
|
-
return result as { content: string };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async write(
|
|
46
|
-
node: string,
|
|
47
|
-
path: string,
|
|
48
|
-
content: string,
|
|
49
|
-
): Promise<{ success: boolean }> {
|
|
50
|
-
const result = await this.sendToolReq(node, "write", { path, content });
|
|
51
|
-
return result as { success: boolean };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async ls(
|
|
55
|
-
node: string,
|
|
56
|
-
path: string,
|
|
57
|
-
): Promise<{ entries: { name: string; type: "file" | "dir"; size: number }[] }> {
|
|
58
|
-
const result = await this.sendToolReq(node, "ls", { path });
|
|
59
|
-
return result as { entries: { name: string; type: "file" | "dir"; size: number }[] };
|
|
36
|
+
): Promise<Record<string, unknown>> {
|
|
37
|
+
return this.sendToolReq(node, tool, params, timeout);
|
|
60
38
|
}
|
|
61
39
|
|
|
62
40
|
private async sendToolReq(
|
|
63
41
|
node: string,
|
|
64
|
-
tool:
|
|
42
|
+
tool: string,
|
|
65
43
|
params: Record<string, unknown>,
|
|
44
|
+
timeout?: number,
|
|
66
45
|
): Promise<Record<string, unknown>> {
|
|
67
46
|
const route = this.peerManager.router.resolveNode(node);
|
|
68
47
|
if (!route) throw new Error(`Node "${node}" not reachable`);
|
|
@@ -78,7 +57,7 @@ export class ToolProxy {
|
|
|
78
57
|
};
|
|
79
58
|
|
|
80
59
|
return new Promise((resolve, reject) => {
|
|
81
|
-
const timeoutMs =
|
|
60
|
+
const timeoutMs = timeout ?? TOOL_TIMEOUT;
|
|
82
61
|
const timer = setTimeout(() => {
|
|
83
62
|
this.pending.delete(id);
|
|
84
63
|
this.peerManager.router.markFailed(id);
|
|
@@ -112,12 +91,11 @@ export class ToolProxy {
|
|
|
112
91
|
}
|
|
113
92
|
}
|
|
114
93
|
|
|
115
|
-
// ── Incoming request: execute
|
|
94
|
+
// ── Incoming request: execute via local Gateway ────────────────
|
|
116
95
|
async handleRequest(frame: ToolProxyRequest): Promise<void> {
|
|
117
96
|
const { id, from, payload } = frame;
|
|
118
97
|
const toolProxyConfig = this.config.toolProxy;
|
|
119
98
|
|
|
120
|
-
// Check if tool proxy is enabled
|
|
121
99
|
if (!toolProxyConfig?.enabled) {
|
|
122
100
|
this.sendResponse(id, from, {
|
|
123
101
|
success: false,
|
|
@@ -126,7 +104,6 @@ export class ToolProxy {
|
|
|
126
104
|
return;
|
|
127
105
|
}
|
|
128
106
|
|
|
129
|
-
// Check tool allowlist/denylist
|
|
130
107
|
if (!this.isToolAllowed(payload.tool, toolProxyConfig)) {
|
|
131
108
|
this.sendResponse(id, from, {
|
|
132
109
|
success: false,
|
|
@@ -136,25 +113,7 @@ export class ToolProxy {
|
|
|
136
113
|
}
|
|
137
114
|
|
|
138
115
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
switch (payload.tool) {
|
|
142
|
-
case "exec":
|
|
143
|
-
result = await this.executeExec(payload.params, toolProxyConfig);
|
|
144
|
-
break;
|
|
145
|
-
case "read":
|
|
146
|
-
result = await this.executeRead(payload.params, toolProxyConfig);
|
|
147
|
-
break;
|
|
148
|
-
case "write":
|
|
149
|
-
result = await this.executeWrite(payload.params, toolProxyConfig);
|
|
150
|
-
break;
|
|
151
|
-
case "ls":
|
|
152
|
-
result = await this.executeLs(payload.params, toolProxyConfig);
|
|
153
|
-
break;
|
|
154
|
-
default:
|
|
155
|
-
throw new Error(`Unknown tool: ${payload.tool}`);
|
|
156
|
-
}
|
|
157
|
-
|
|
116
|
+
const result = await this.executeViaGateway(payload.tool, payload.params);
|
|
158
117
|
this.sendResponse(id, from, { success: true, result });
|
|
159
118
|
} catch (err) {
|
|
160
119
|
this.sendResponse(id, from, {
|
|
@@ -179,141 +138,56 @@ export class ToolProxy {
|
|
|
179
138
|
} as ToolProxyResponse);
|
|
180
139
|
}
|
|
181
140
|
|
|
182
|
-
// ──
|
|
183
|
-
private async
|
|
141
|
+
// ── Gateway tool invocation ────────────────────────────────────
|
|
142
|
+
private async executeViaGateway(
|
|
143
|
+
tool: string,
|
|
184
144
|
params: Record<string, unknown>,
|
|
185
|
-
tpConfig: ToolProxyConfig,
|
|
186
145
|
): Promise<Record<string, unknown>> {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const cwd = (params.cwd as string) ?? process.cwd();
|
|
199
|
-
const timeout = (params.timeout as number) ?? TOOL_TIMEOUT;
|
|
200
|
-
|
|
201
|
-
const proc = Bun.spawn(["sh", "-c", command], {
|
|
202
|
-
cwd,
|
|
203
|
-
stdout: "pipe",
|
|
204
|
-
stderr: "pipe",
|
|
146
|
+
const { port, authHeader } = this.gatewayInfo;
|
|
147
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
148
|
+
if (authHeader) headers["Authorization"] = authHeader;
|
|
149
|
+
|
|
150
|
+
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify({ tool, args: params }),
|
|
205
154
|
});
|
|
206
155
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
new Response(proc.stderr).text(),
|
|
212
|
-
]);
|
|
213
|
-
const exitCode = await proc.exited;
|
|
214
|
-
clearTimeout(timeoutId);
|
|
215
|
-
|
|
216
|
-
const maxBytes = tpConfig.maxOutputBytes;
|
|
217
|
-
return {
|
|
218
|
-
exitCode,
|
|
219
|
-
stdout: stdout.slice(0, maxBytes),
|
|
220
|
-
stderr: stderr.slice(0, maxBytes),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private async executeRead(
|
|
225
|
-
params: Record<string, unknown>,
|
|
226
|
-
tpConfig: ToolProxyConfig,
|
|
227
|
-
): Promise<Record<string, unknown>> {
|
|
228
|
-
const path = params.path as string;
|
|
229
|
-
if (!path) throw new Error("Missing path");
|
|
230
|
-
this.validatePath(path, tpConfig);
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const text = await res.text();
|
|
158
|
+
throw new Error(`Gateway tool invocation failed (${res.status}): ${text}`);
|
|
159
|
+
}
|
|
231
160
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
161
|
+
const body = (await res.json()) as {
|
|
162
|
+
ok: boolean;
|
|
163
|
+
result?: unknown;
|
|
164
|
+
error?: { type: string; message: string };
|
|
236
165
|
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
private async executeWrite(
|
|
240
|
-
params: Record<string, unknown>,
|
|
241
|
-
tpConfig: ToolProxyConfig,
|
|
242
|
-
): Promise<Record<string, unknown>> {
|
|
243
|
-
const path = params.path as string;
|
|
244
|
-
const content = params.content as string;
|
|
245
|
-
if (!path || content === undefined) throw new Error("Missing path or content");
|
|
246
|
-
this.validatePath(path, tpConfig);
|
|
247
|
-
|
|
248
|
-
await Bun.write(path, content);
|
|
249
|
-
return { success: true };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
private async executeLs(
|
|
253
|
-
params: Record<string, unknown>,
|
|
254
|
-
tpConfig: ToolProxyConfig,
|
|
255
|
-
): Promise<Record<string, unknown>> {
|
|
256
|
-
const path = params.path as string;
|
|
257
|
-
if (!path) throw new Error("Missing path");
|
|
258
|
-
this.validatePath(path, tpConfig);
|
|
259
166
|
|
|
260
|
-
|
|
261
|
-
|
|
167
|
+
if (!body.ok) {
|
|
168
|
+
throw new Error(body.error?.message ?? "Tool invocation failed");
|
|
169
|
+
}
|
|
262
170
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
entries.push({
|
|
268
|
-
name: entry.name,
|
|
269
|
-
type: entry.isDirectory() ? "dir" : "file",
|
|
270
|
-
size: Number(st.size),
|
|
271
|
-
});
|
|
272
|
-
} catch {
|
|
273
|
-
entries.push({
|
|
274
|
-
name: entry.name,
|
|
275
|
-
type: entry.isDirectory() ? "dir" : "file",
|
|
276
|
-
size: 0,
|
|
277
|
-
});
|
|
278
|
-
}
|
|
171
|
+
const maxBytes = this.config.toolProxy?.maxOutputBytes ?? 1_048_576;
|
|
172
|
+
const resultStr = JSON.stringify(body.result);
|
|
173
|
+
if (resultStr.length > maxBytes) {
|
|
174
|
+
return { truncated: true, result: resultStr.slice(0, maxBytes) };
|
|
279
175
|
}
|
|
280
176
|
|
|
281
|
-
return
|
|
177
|
+
return typeof body.result === "object" && body.result !== null
|
|
178
|
+
? (body.result as Record<string, unknown>)
|
|
179
|
+
: { result: body.result };
|
|
282
180
|
}
|
|
283
181
|
|
|
284
182
|
// ── Security ───────────────────────────────────────────────────
|
|
285
|
-
private isToolAllowed(
|
|
286
|
-
tool
|
|
287
|
-
tpConfig: ToolProxyConfig,
|
|
288
|
-
): boolean {
|
|
289
|
-
if (tpConfig.deny.includes(tool as "exec" | "read" | "write" | "ls")) return false;
|
|
183
|
+
private isToolAllowed(tool: string, tpConfig: ToolProxyConfig): boolean {
|
|
184
|
+
if (tpConfig.deny.includes(tool)) return false;
|
|
290
185
|
if (tpConfig.allow.length > 0) {
|
|
291
|
-
return tpConfig.allow.includes(tool
|
|
186
|
+
return tpConfig.allow.includes(tool);
|
|
292
187
|
}
|
|
293
188
|
return true;
|
|
294
189
|
}
|
|
295
190
|
|
|
296
|
-
private validatePath(path: string, tpConfig: ToolProxyConfig) {
|
|
297
|
-
const normalized = normalize(resolve(path));
|
|
298
|
-
|
|
299
|
-
// Check deny paths first
|
|
300
|
-
for (const denied of tpConfig.denyPaths) {
|
|
301
|
-
if (normalized.startsWith(normalize(resolve(denied)))) {
|
|
302
|
-
throw new Error(`Path "${path}" is in denied path prefix`);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Check allow paths
|
|
307
|
-
if (tpConfig.allowPaths.length > 0) {
|
|
308
|
-
const allowed = tpConfig.allowPaths.some((p) =>
|
|
309
|
-
normalized.startsWith(normalize(resolve(p))),
|
|
310
|
-
);
|
|
311
|
-
if (!allowed) {
|
|
312
|
-
throw new Error(`Path "${path}" is not in allowed path prefixes`);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
191
|
destroy() {
|
|
318
192
|
for (const [, pending] of this.pending) {
|
|
319
193
|
clearTimeout(pending.timer);
|
|
@@ -19,28 +19,32 @@ export function createClusterExecTool(): AnyAgentTool {
|
|
|
19
19
|
type: "string",
|
|
20
20
|
description: "Shell command to execute",
|
|
21
21
|
},
|
|
22
|
-
|
|
22
|
+
workdir: {
|
|
23
23
|
type: "string",
|
|
24
24
|
description: "Working directory (optional)",
|
|
25
25
|
},
|
|
26
26
|
timeout: {
|
|
27
27
|
type: "number",
|
|
28
|
-
description: "Timeout in
|
|
28
|
+
description: "Timeout in seconds (default 1800)",
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
31
|
required: ["node", "command"],
|
|
32
32
|
},
|
|
33
33
|
async execute(_toolCallId, params) {
|
|
34
|
-
const { node, command,
|
|
34
|
+
const { node, command, workdir, timeout } = params as {
|
|
35
35
|
node: string;
|
|
36
36
|
command: string;
|
|
37
|
-
|
|
37
|
+
workdir?: string;
|
|
38
38
|
timeout?: number;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
try {
|
|
42
42
|
const runtime = getClusterRuntime();
|
|
43
|
-
const result = await runtime.toolProxy.
|
|
43
|
+
const result = await runtime.toolProxy.invoke(
|
|
44
|
+
node,
|
|
45
|
+
"exec",
|
|
46
|
+
{ command, workdir, timeout },
|
|
47
|
+
);
|
|
44
48
|
return {
|
|
45
49
|
content: [
|
|
46
50
|
{
|
|
@@ -15,7 +15,7 @@ export function createClusterReadTool(): AnyAgentTool {
|
|
|
15
15
|
},
|
|
16
16
|
path: {
|
|
17
17
|
type: "string",
|
|
18
|
-
description: "
|
|
18
|
+
description: "File path to read",
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
21
|
required: ["node", "path"],
|
|
@@ -25,10 +25,13 @@ export function createClusterReadTool(): AnyAgentTool {
|
|
|
25
25
|
|
|
26
26
|
try {
|
|
27
27
|
const runtime = getClusterRuntime();
|
|
28
|
-
const result = await runtime.toolProxy.
|
|
28
|
+
const result = await runtime.toolProxy.invoke(node, "read", { path });
|
|
29
29
|
return {
|
|
30
30
|
content: [
|
|
31
|
-
{
|
|
31
|
+
{
|
|
32
|
+
type: "text" as const,
|
|
33
|
+
text: JSON.stringify(result, null, 2),
|
|
34
|
+
},
|
|
32
35
|
],
|
|
33
36
|
details: result,
|
|
34
37
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
+
|
|
4
|
+
export function createClusterToolTool(): AnyAgentTool {
|
|
5
|
+
return {
|
|
6
|
+
name: "cluster_tool",
|
|
7
|
+
label: "Cluster Tool",
|
|
8
|
+
description:
|
|
9
|
+
"Invoke any OpenClaw tool on a remote cluster node. " +
|
|
10
|
+
"Supports all tools available on the remote node (exec, read, write, edit, " +
|
|
11
|
+
"web_search, web_fetch, browser, process, etc.). " +
|
|
12
|
+
"Use nodeId or 'tags:<tag>' to specify the target.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
node: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: 'Target nodeId or "tags:<tag>"',
|
|
19
|
+
},
|
|
20
|
+
tool: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Tool name to invoke (e.g. exec, read, write, edit, web_search, web_fetch, browser, process)",
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
type: "object",
|
|
26
|
+
description: "Tool arguments (tool-specific)",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ["node", "tool", "args"],
|
|
30
|
+
},
|
|
31
|
+
async execute(_toolCallId, params) {
|
|
32
|
+
const { node, tool, args } = params as {
|
|
33
|
+
node: string;
|
|
34
|
+
tool: string;
|
|
35
|
+
args: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const runtime = getClusterRuntime();
|
|
40
|
+
const result = await runtime.toolProxy.invoke(node, tool, args);
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text" as const,
|
|
45
|
+
text: JSON.stringify(result, null, 2),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
details: result,
|
|
49
|
+
};
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "text" as const,
|
|
55
|
+
text: `Tool error: ${err instanceof Error ? err.message : String(err)}`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
details: { error: true },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -15,7 +15,7 @@ export function createClusterWriteTool(): AnyAgentTool {
|
|
|
15
15
|
},
|
|
16
16
|
path: {
|
|
17
17
|
type: "string",
|
|
18
|
-
description: "
|
|
18
|
+
description: "File path to write",
|
|
19
19
|
},
|
|
20
20
|
content: {
|
|
21
21
|
type: "string",
|
|
@@ -33,14 +33,16 @@ export function createClusterWriteTool(): AnyAgentTool {
|
|
|
33
33
|
|
|
34
34
|
try {
|
|
35
35
|
const runtime = getClusterRuntime();
|
|
36
|
-
const result = await runtime.toolProxy.
|
|
36
|
+
const result = await runtime.toolProxy.invoke(
|
|
37
|
+
node,
|
|
38
|
+
"write",
|
|
39
|
+
{ path, content },
|
|
40
|
+
);
|
|
37
41
|
return {
|
|
38
42
|
content: [
|
|
39
43
|
{
|
|
40
44
|
type: "text" as const,
|
|
41
|
-
text: result
|
|
42
|
-
? `Successfully wrote to ${path} on ${node}`
|
|
43
|
-
: `Write failed on ${node}`,
|
|
45
|
+
text: JSON.stringify(result, null, 2),
|
|
44
46
|
},
|
|
45
47
|
],
|
|
46
48
|
details: result,
|
package/src/types.ts
CHANGED
package/src/tools/cluster-ls.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
2
|
-
import { getClusterRuntime } from "../cluster-service.ts";
|
|
3
|
-
|
|
4
|
-
export function createClusterLsTool(): AnyAgentTool {
|
|
5
|
-
return {
|
|
6
|
-
name: "cluster_ls",
|
|
7
|
-
label: "Cluster Ls",
|
|
8
|
-
description: "List directory contents on a remote cluster node.",
|
|
9
|
-
parameters: {
|
|
10
|
-
type: "object",
|
|
11
|
-
properties: {
|
|
12
|
-
node: {
|
|
13
|
-
type: "string",
|
|
14
|
-
description: 'Target nodeId or "tags:<tag>"',
|
|
15
|
-
},
|
|
16
|
-
path: {
|
|
17
|
-
type: "string",
|
|
18
|
-
description: "Absolute directory path to list",
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
required: ["node", "path"],
|
|
22
|
-
},
|
|
23
|
-
async execute(_toolCallId, params) {
|
|
24
|
-
const { node, path } = params as { node: string; path: string };
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
const runtime = getClusterRuntime();
|
|
28
|
-
const result = await runtime.toolProxy.ls(node, path);
|
|
29
|
-
return {
|
|
30
|
-
content: [
|
|
31
|
-
{
|
|
32
|
-
type: "text" as const,
|
|
33
|
-
text: JSON.stringify(result.entries, null, 2),
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
details: result,
|
|
37
|
-
};
|
|
38
|
-
} catch (err) {
|
|
39
|
-
return {
|
|
40
|
-
content: [
|
|
41
|
-
{
|
|
42
|
-
type: "text" as const,
|
|
43
|
-
text: `Ls error: ${err instanceof Error ? err.message : String(err)}`,
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
details: { error: true },
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|