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 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** — Execute single tool calls (shell commands, file read/write, directory listing) on remote nodes without delegating the entire task.
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", "ls"],
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 tools: `"exec"`, `"read"`, `"write"`, `"ls"` |
126
- | `deny` | array | `[]` | Denied tools (takes precedence over allow) |
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
- - `cwd`: Optional working directory
155
- - `timeout`: Optional timeout in ms (default 30000)
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`: Absolute file 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`: Absolute file path
160
+ - `path`: File path
166
161
  - `content`: File content
167
162
 
168
- ### cluster_ls
169
- List directory contents on a remote node.
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
- - `path`: Directory path
166
+ - `tool`: OpenClaw tool name
167
+ - `args`: Tool arguments (tool-specific)
172
168
 
173
169
  ## Verify cluster status
174
170
 
@@ -55,11 +55,18 @@
55
55
  "type": "object",
56
56
  "properties": {
57
57
  "enabled": { "type": "boolean", "default": false },
58
- "allow": { "type": "array", "items": { "type": "string" }, "default": [] },
59
- "deny": { "type": "array", "items": { "type": "string" }, "default": [] },
60
- "allowPaths": { "type": "array", "items": { "type": "string" }, "default": [] },
61
- "denyPaths": { "type": "array", "items": { "type": "string" }, "default": [] },
62
- "execAllowlist": { "type": "array", "items": { "type": "string" }, "default": [] },
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.0",
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": {
@@ -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
- Bun.spawn(["openclaw", "agent", "--message", message], {
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.enum(["exec", "read", "write", "ls"])).default([]),
23
- deny: z.array(z.enum(["exec", "read", "write", "ls"])).default([]),
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 = Bun.spawn(["openclaw", "agent", "--message", message], {
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 { createClusterLsTool } from "./tools/cluster-ls.ts";
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(createClusterLsTool(), { optional: true });
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, cluster_ls",
91
+ "cluster_exec, cluster_read, cluster_write, cluster_tool",
92
92
  );
93
93
 
94
94
  return { prependContext: lines.join("\n") };
@@ -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: ReturnType<typeof Bun.serve> | null = null;
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 = Bun.serve({
34
- port: this.config.proxyPort,
35
- hostname: "127.0.0.1",
36
- routes: {
37
- "/v1/chat/completions": {
38
- POST: async (req) => this.handleChatCompletion(req),
39
- },
40
- "/v1/models": {
41
- GET: () => this.handleListModels(),
42
- },
43
- },
44
- fetch() {
45
- return new Response("Not Found", { status: 404 });
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.stop();
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(req: Request): Promise<Response> {
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 = (await req.json()) as typeof body;
106
+ body = JSON.parse(rawBody);
74
107
  } catch {
75
- return new Response(JSON.stringify({ error: "Invalid JSON" }), {
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 new Response(
85
- JSON.stringify({ error: { message: `Model "${modelId}" not found in cluster` } }),
86
- { status: 404, headers: { "Content-Type": "application/json" } },
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
- ): Response {
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 new Response(readable, {
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<Response> {
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 new Response(
203
- JSON.stringify({ error: { message: result.error } }),
204
- { status: 502, headers: { "Content-Type": "application/json" } },
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
- // OpenAI-compatible response
209
- return new Response(
210
- JSON.stringify({
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
- { headers: { "Content-Type": "application/json" } },
231
- );
269
+ };
232
270
  } catch (err) {
233
- return new Response(
234
- JSON.stringify({ error: { message: err instanceof Error ? err.message : String(err) } }),
235
- { status: 502, headers: { "Content-Type": "application/json" } },
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(): Response {
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 new Response(
253
- JSON.stringify({ object: "list", data: models }),
254
- { headers: { "Content-Type": "application/json" } },
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
 
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
- import type { ServerWebSocket } from "bun";
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 wsServer: ReturnType<typeof Bun.serve> | null = null;
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 ServerWebSocket to Connection for inbound connections. */
33
- private inboundConnections = new Map<ServerWebSocket<unknown>, Connection>();
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.wsServer) {
80
- this.wsServer.stop();
81
- this.wsServer = null;
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 (Bun.serve) ─────────────────────────────
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
- const self = this;
90
-
91
- this.wsServer = Bun.serve<undefined>({
92
- port,
93
- hostname,
94
- fetch(req, server) {
95
- if (server.upgrade(req)) {
96
- return undefined;
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
- return new Response("WebSocket upgrade required", { status: 426 });
99
- },
100
- websocket: {
101
- open(ws) {
102
- self.handleInboundOpen(ws);
103
- },
104
- message(ws, message) {
105
- const conn = self.inboundConnections.get(ws);
106
- if (conn) {
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: ServerWebSocket<unknown>) {
122
- // Wrap Bun's ServerWebSocket into our WsTransport interface
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 exec(
31
+ async invoke(
27
32
  node: string,
28
- command: string,
29
- cwd?: string,
33
+ tool: string,
34
+ params: Record<string, unknown>,
30
35
  timeout?: number,
31
- ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
32
- const result = await this.sendToolReq(node, "exec", {
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: ToolProxyRequest["payload"]["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 = (params.timeout as number | undefined) ?? TOOL_TIMEOUT;
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 locally ──────────────────────────
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
- let result: Record<string, unknown>;
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
- // ── Local execution ────────────────────────────────────────────
183
- private async executeExec(
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 command = params.command as string;
188
- if (!command) throw new Error("Missing command");
189
-
190
- // Check exec allowlist
191
- if (tpConfig.execAllowlist.length > 0) {
192
- const base = command.split(/\s+/)[0]!;
193
- if (!tpConfig.execAllowlist.includes(base)) {
194
- throw new Error(`Command "${base}" not in exec allowlist`);
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
- const timeoutId = setTimeout(() => proc.kill(), timeout);
208
-
209
- const [stdout, stderr] = await Promise.all([
210
- new Response(proc.stdout).text(),
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 file = Bun.file(path);
233
- const content = await file.text();
234
- return {
235
- content: content.slice(0, tpConfig.maxOutputBytes),
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
- const dirEntries = await readdir(path, { withFileTypes: true });
261
- const entries: { name: string; type: "file" | "dir"; size: number }[] = [];
167
+ if (!body.ok) {
168
+ throw new Error(body.error?.message ?? "Tool invocation failed");
169
+ }
262
170
 
263
- for (const entry of dirEntries) {
264
- const fullPath = join(path, entry.name);
265
- try {
266
- const st = await stat(fullPath);
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 { entries };
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: string,
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 as "exec" | "read" | "write" | "ls");
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
- cwd: {
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 milliseconds (default 30000)",
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, cwd, timeout } = params as {
34
+ const { node, command, workdir, timeout } = params as {
35
35
  node: string;
36
36
  command: string;
37
- cwd?: string;
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.exec(node, command, cwd, timeout);
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: "Absolute file path to read",
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.read(node, path);
28
+ const result = await runtime.toolProxy.invoke(node, "read", { path });
29
29
  return {
30
30
  content: [
31
- { type: "text" as const, text: result.content },
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: "Absolute file path to write",
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.write(node, path, content);
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.success
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
@@ -133,7 +133,7 @@ export interface ToolProxyRequest extends ClusterFrame {
133
133
  type: "tool_req";
134
134
  id: string;
135
135
  payload: {
136
- tool: "exec" | "read" | "write" | "ls";
136
+ tool: string;
137
137
  params: Record<string, unknown>;
138
138
  };
139
139
  }
@@ -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
- }