clawmatrix 0.1.1 → 0.1.3

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. `["*"]` or `[]` = 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. Use [\"*\"] for all. Empty or [\"*\"] = 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.1",
3
+ "version": "0.1.3",
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",
@@ -1,6 +1,7 @@
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,7 +9,7 @@ import { spawnProcess } from "./compat.ts";
8
9
  import { PeerManager } from "./peer-manager.ts";
9
10
  import { HandoffManager } from "./handoff.ts";
10
11
  import { ModelProxy } from "./model-proxy.ts";
11
- import { ToolProxy } from "./tool-proxy.ts";
12
+ import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
12
13
  import type {
13
14
  AnyClusterFrame,
14
15
  HandoffRequest,
@@ -21,6 +22,18 @@ import type {
21
22
  ToolProxyResponse,
22
23
  } from "./types.ts";
23
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
+
24
37
  /** Singleton cluster state shared across plugin components. */
25
38
  export class ClusterRuntime {
26
39
  readonly config: ClawMatrixConfig;
@@ -30,13 +43,14 @@ export class ClusterRuntime {
30
43
  readonly toolProxy: ToolProxy;
31
44
  private logger: PluginLogger;
32
45
 
33
- constructor(config: ClawMatrixConfig, logger: PluginLogger) {
46
+ constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig) {
34
47
  this.config = config;
35
48
  this.logger = logger;
49
+ const gatewayInfo = resolveGatewayInfo(openclawConfig);
36
50
  this.peerManager = new PeerManager(config);
37
51
  this.handoffManager = new HandoffManager(config, this.peerManager);
38
- this.modelProxy = new ModelProxy(config, this.peerManager);
39
- this.toolProxy = new ToolProxy(config, this.peerManager);
52
+ this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo);
53
+ this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo);
40
54
  }
41
55
 
42
56
  start() {
@@ -124,8 +138,8 @@ export class ClusterRuntime {
124
138
  return;
125
139
  }
126
140
 
127
- // Fire-and-forget: inject message via openclaw agent
128
- spawnProcess(["openclaw", "agent", "--message", message], {
141
+ // Fire-and-forget: inject message via openclaw agent CLI
142
+ spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
129
143
  stdout: "ignore",
130
144
  stderr: "ignore",
131
145
  });
@@ -144,11 +158,12 @@ export function getClusterRuntime(): ClusterRuntime {
144
158
 
145
159
  export function createClusterService(
146
160
  config: ClawMatrixConfig,
161
+ openclawConfig: OpenClawConfig,
147
162
  ): OpenClawPluginService {
148
163
  return {
149
164
  id: "clawmatrix",
150
165
  start(ctx: OpenClawPluginServiceContext) {
151
- clusterRuntime = new ClusterRuntime(config, ctx.logger);
166
+ clusterRuntime = new ClusterRuntime(config, ctx.logger, openclawConfig);
152
167
  clusterRuntime.start();
153
168
  },
154
169
  async stop() {
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
@@ -148,10 +148,10 @@ export class HandoffManager {
148
148
  ? `${payload.task}\n\nContext:\n${payload.context}`
149
149
  : payload.task;
150
150
 
151
- const proc = spawnProcess(["openclaw", "agent", "--message", message], {
152
- stdout: "pipe",
153
- stderr: "pipe",
154
- });
151
+ const proc = spawnProcess(
152
+ ["openclaw", "agent", "--agent", agent.id, "--message", message],
153
+ { stdout: "pipe", stderr: "pipe" },
154
+ );
155
155
 
156
156
  const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
157
157
  const exitCode = await proc.exited;
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,6 +1,7 @@
1
1
  import { createServer, type Server } from "node:http";
2
2
  import type { PeerManager } from "./peer-manager.ts";
3
3
  import type { ClawMatrixConfig } from "./config.ts";
4
+ import type { GatewayInfo } from "./tool-proxy.ts";
4
5
  import type {
5
6
  ModelRequest,
6
7
  ModelResponse,
@@ -23,10 +24,12 @@ export class ModelProxy {
23
24
  private peerManager: PeerManager;
24
25
  private pending = new Map<string, PendingModelReq>();
25
26
  private httpServer: Server | null = null;
27
+ private gatewayInfo: GatewayInfo;
26
28
 
27
- constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
29
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
28
30
  this.config = config;
29
31
  this.peerManager = peerManager;
32
+ this.gatewayInfo = gatewayInfo;
30
33
  }
31
34
 
32
35
  /** Start the local HTTP proxy server for OpenAI-compatible requests. */
@@ -372,11 +375,13 @@ export class ModelProxy {
372
375
  }
373
376
 
374
377
  try {
375
- const localUrl = `http://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT ?? 3000}/v1/chat/completions`;
378
+ const { port, authHeader } = this.gatewayInfo;
379
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
380
+ if (authHeader) headers["Authorization"] = authHeader;
376
381
 
377
- const response = await fetch(localUrl, {
382
+ const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
378
383
  method: "POST",
379
- headers: { "Content-Type": "application/json" },
384
+ headers,
380
385
  body: JSON.stringify({
381
386
  model: `${model.provider}/${model.id}`,
382
387
  messages: payload.messages,
package/src/tool-proxy.ts CHANGED
@@ -1,8 +1,5 @@
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
- import { spawnProcess, readFileText, writeFileText } from "./compat.ts";
6
3
  import type { ToolProxyRequest, ToolProxyResponse } from "./types.ts";
7
4
 
8
5
  const TOOL_TIMEOUT = 30_000;
@@ -13,57 +10,38 @@ interface PendingToolReq {
13
10
  timer: ReturnType<typeof setTimeout>;
14
11
  }
15
12
 
13
+ export interface GatewayInfo {
14
+ port: number;
15
+ authHeader?: string;
16
+ }
17
+
16
18
  export class ToolProxy {
17
19
  private config: ClawMatrixConfig;
18
20
  private peerManager: PeerManager;
19
21
  private pending = new Map<string, PendingToolReq>();
22
+ private gatewayInfo: GatewayInfo;
20
23
 
21
- constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
24
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
22
25
  this.config = config;
23
26
  this.peerManager = peerManager;
27
+ this.gatewayInfo = gatewayInfo;
24
28
  }
25
29
 
26
30
  // ── Outbound: request remote execution ─────────────────────────
27
- async exec(
31
+ async invoke(
28
32
  node: string,
29
- command: string,
30
- cwd?: string,
33
+ tool: string,
34
+ params: Record<string, unknown>,
31
35
  timeout?: number,
32
- ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
33
- const result = await this.sendToolReq(node, "exec", {
34
- command,
35
- cwd,
36
- timeout: timeout ?? TOOL_TIMEOUT,
37
- });
38
- return result as { exitCode: number; stdout: string; stderr: string };
39
- }
40
-
41
- async read(node: string, path: string): Promise<{ content: string }> {
42
- const result = await this.sendToolReq(node, "read", { path });
43
- return result as { content: string };
44
- }
45
-
46
- async write(
47
- node: string,
48
- path: string,
49
- content: string,
50
- ): Promise<{ success: boolean }> {
51
- const result = await this.sendToolReq(node, "write", { path, content });
52
- return result as { success: boolean };
53
- }
54
-
55
- async ls(
56
- node: string,
57
- path: string,
58
- ): Promise<{ entries: { name: string; type: "file" | "dir"; size: number }[] }> {
59
- const result = await this.sendToolReq(node, "ls", { path });
60
- return result as { entries: { name: string; type: "file" | "dir"; size: number }[] };
36
+ ): Promise<Record<string, unknown>> {
37
+ return this.sendToolReq(node, tool, params, timeout);
61
38
  }
62
39
 
63
40
  private async sendToolReq(
64
41
  node: string,
65
- tool: ToolProxyRequest["payload"]["tool"],
42
+ tool: string,
66
43
  params: Record<string, unknown>,
44
+ timeout?: number,
67
45
  ): Promise<Record<string, unknown>> {
68
46
  const route = this.peerManager.router.resolveNode(node);
69
47
  if (!route) throw new Error(`Node "${node}" not reachable`);
@@ -79,7 +57,7 @@ export class ToolProxy {
79
57
  };
80
58
 
81
59
  return new Promise((resolve, reject) => {
82
- const timeoutMs = (params.timeout as number | undefined) ?? TOOL_TIMEOUT;
60
+ const timeoutMs = timeout ?? TOOL_TIMEOUT;
83
61
  const timer = setTimeout(() => {
84
62
  this.pending.delete(id);
85
63
  this.peerManager.router.markFailed(id);
@@ -113,12 +91,11 @@ export class ToolProxy {
113
91
  }
114
92
  }
115
93
 
116
- // ── Incoming request: execute locally ──────────────────────────
94
+ // ── Incoming request: execute via local Gateway ────────────────
117
95
  async handleRequest(frame: ToolProxyRequest): Promise<void> {
118
96
  const { id, from, payload } = frame;
119
97
  const toolProxyConfig = this.config.toolProxy;
120
98
 
121
- // Check if tool proxy is enabled
122
99
  if (!toolProxyConfig?.enabled) {
123
100
  this.sendResponse(id, from, {
124
101
  success: false,
@@ -127,7 +104,6 @@ export class ToolProxy {
127
104
  return;
128
105
  }
129
106
 
130
- // Check tool allowlist/denylist
131
107
  if (!this.isToolAllowed(payload.tool, toolProxyConfig)) {
132
108
  this.sendResponse(id, from, {
133
109
  success: false,
@@ -137,25 +113,7 @@ export class ToolProxy {
137
113
  }
138
114
 
139
115
  try {
140
- let result: Record<string, unknown>;
141
-
142
- switch (payload.tool) {
143
- case "exec":
144
- result = await this.executeExec(payload.params, toolProxyConfig);
145
- break;
146
- case "read":
147
- result = await this.executeRead(payload.params, toolProxyConfig);
148
- break;
149
- case "write":
150
- result = await this.executeWrite(payload.params, toolProxyConfig);
151
- break;
152
- case "ls":
153
- result = await this.executeLs(payload.params, toolProxyConfig);
154
- break;
155
- default:
156
- throw new Error(`Unknown tool: ${payload.tool}`);
157
- }
158
-
116
+ const result = await this.executeViaGateway(payload.tool, payload.params);
159
117
  this.sendResponse(id, from, { success: true, result });
160
118
  } catch (err) {
161
119
  this.sendResponse(id, from, {
@@ -180,138 +138,52 @@ export class ToolProxy {
180
138
  } as ToolProxyResponse);
181
139
  }
182
140
 
183
- // ── Local execution ────────────────────────────────────────────
184
- private async executeExec(
141
+ // ── Gateway tool invocation ────────────────────────────────────
142
+ private async executeViaGateway(
143
+ tool: string,
185
144
  params: Record<string, unknown>,
186
- tpConfig: ToolProxyConfig,
187
145
  ): Promise<Record<string, unknown>> {
188
- const command = params.command as string;
189
- if (!command) throw new Error("Missing command");
190
-
191
- // Check exec allowlist
192
- if (tpConfig.execAllowlist.length > 0) {
193
- const base = command.split(/\s+/)[0]!;
194
- if (!tpConfig.execAllowlist.includes(base)) {
195
- throw new Error(`Command "${base}" not in exec allowlist`);
196
- }
197
- }
198
-
199
- const cwd = (params.cwd as string) ?? process.cwd();
200
- const timeout = (params.timeout as number) ?? TOOL_TIMEOUT;
201
-
202
- const proc = spawnProcess(["sh", "-c", command], {
203
- cwd,
204
- stdout: "pipe",
205
- 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 }),
206
154
  });
207
155
 
208
- const timeoutId = setTimeout(() => proc.kill(), timeout);
209
-
210
- const [stdout, stderr] = await Promise.all([
211
- proc.stdout ? new Response(proc.stdout).text() : "",
212
- proc.stderr ? new Response(proc.stderr).text() : "",
213
- ]);
214
- const exitCode = await proc.exited;
215
- clearTimeout(timeoutId);
216
-
217
- const maxBytes = tpConfig.maxOutputBytes;
218
- return {
219
- exitCode,
220
- stdout: stdout.slice(0, maxBytes),
221
- stderr: stderr.slice(0, maxBytes),
222
- };
223
- }
224
-
225
- private async executeRead(
226
- params: Record<string, unknown>,
227
- tpConfig: ToolProxyConfig,
228
- ): Promise<Record<string, unknown>> {
229
- const path = params.path as string;
230
- if (!path) throw new Error("Missing path");
231
- 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
+ }
232
160
 
233
- const content = await readFileText(path);
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
166
 
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 writeFileText(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
-
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;
290
- if (tpConfig.allow.length > 0) {
291
- return tpConfig.allow.includes(tool as "exec" | "read" | "write" | "ls");
292
- }
293
- return true;
294
- }
295
-
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
- }
183
+ private isToolAllowed(tool: string, tpConfig: ToolProxyConfig): boolean {
184
+ if (tpConfig.deny.includes(tool)) return false;
185
+ if (tpConfig.allow.length === 0 || tpConfig.allow.includes("*")) return true;
186
+ return tpConfig.allow.includes(tool);
315
187
  }
316
188
 
317
189
  destroy() {
@@ -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
- }