Compare commits

...

7 Commits

Author SHA1 Message Date
Peter Steinberger
bfd9fcac18 test: remove redundant task flow temp dir args 2026-06-04 23:40:09 +01:00
Peter Steinberger
4f7b5d8f44 fix: refresh node plugin tools after plugin load 2026-06-04 23:39:46 +01:00
Peter Steinberger
32caafd4ed test: align rebased runtime defaults 2026-06-04 23:39:46 +01:00
Peter Steinberger
60becfb941 fix: avoid node plugin tool registry cycle 2026-06-04 23:39:45 +01:00
Peter Steinberger
3f4ea59779 build: refresh generated gateway protocol 2026-06-04 23:39:45 +01:00
Peter Steinberger
cde2b5f718 fix: keep node plugin tools fresh 2026-06-04 23:39:45 +01:00
Peter Steinberger
2af75a93c2 feat: expose node-hosted plugin tools 2026-06-04 23:39:45 +01:00
51 changed files with 2531 additions and 52 deletions

View File

@@ -52,6 +52,7 @@ public struct ConnectParams: Codable, Sendable {
public let client: [String: AnyCodable]
public let caps: [String]?
public let commands: [String]?
public let nodeplugintools: [NodePluginToolDescriptor]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
@@ -67,6 +68,7 @@ public struct ConnectParams: Codable, Sendable {
client: [String: AnyCodable],
caps: [String]?,
commands: [String]?,
nodeplugintools: [NodePluginToolDescriptor]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
@@ -81,6 +83,7 @@ public struct ConnectParams: Codable, Sendable {
self.client = client
self.caps = caps
self.commands = commands
self.nodeplugintools = nodeplugintools
self.permissions = permissions
self.pathenv = pathenv
self.role = role
@@ -97,6 +100,7 @@ public struct ConnectParams: Codable, Sendable {
case client
case caps
case commands
case nodeplugintools = "nodePluginTools"
case permissions
case pathenv = "pathEnv"
case role
@@ -1128,6 +1132,54 @@ public struct NodeRenameParams: Codable, Sendable {
public struct NodeListParams: Codable, Sendable {}
public struct NodePluginToolDescriptor: Codable, Sendable {
public let pluginid: String
public let name: String
public let description: String
public let parameters: [String: AnyCodable]?
public let command: String?
public let mcp: [String: AnyCodable]?
public init(
pluginid: String,
name: String,
description: String,
parameters: [String: AnyCodable]?,
command: String?,
mcp: [String: AnyCodable]?)
{
self.pluginid = pluginid
self.name = name
self.description = description
self.parameters = parameters
self.command = command
self.mcp = mcp
}
private enum CodingKeys: String, CodingKey {
case pluginid = "pluginId"
case name
case description
case parameters
case command
case mcp
}
}
public struct NodePluginToolsUpdateParams: Codable, Sendable {
public let tools: [NodePluginToolDescriptor]
public init(
tools: [NodePluginToolDescriptor])
{
self.tools = tools
}
private enum CodingKeys: String, CodingKey {
case tools
}
}
public struct NodePendingAckParams: Codable, Sendable {
public let ids: [String]

View File

@@ -25,6 +25,13 @@ Common use cases:
Execution is still guarded by **exec approvals** and per-agent allowlists on the
node host, so you can keep command access scoped and explicit.
Gateway-loaded plugins can also register node-host commands. When a registered
command includes `agentTool` metadata, `openclaw node run` advertises that
plugin or MCP-backed tool to the Gateway while the node is connected. The agent
sees it as a normal plugin tool, but execution still goes through `node.invoke`
and the node command allowlist, so disconnecting the node removes the tool from
new agent runs.
## Browser proxy (zero-config)
Node hosts automatically advertise a browser proxy if `browser.enabled` is not

View File

@@ -270,6 +270,13 @@ Nodes declare capability claims at connect time:
- `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
The Gateway treats these as **claims** and enforces server-side allowlists.
Connected nodes can publish optional agent-visible plugin or MCP tool
descriptors with `node.pluginTools.update` after a successful connect, after
reconnect, or after a local plugin/MCP inventory change. Each descriptor must
use a provider-safe tool `name` and name a `command` in the node's current
command allowlist. The Gateway filters descriptors outside the approved command
surface, removes them when the node disconnects, and rejects operator attempts
to mutate another node's catalog.
## Presence
@@ -461,6 +468,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `node.invoke` forwards a command to a connected node.
- `node.invoke.result` returns the result for an invoke request.
- `node.event` carries node-originated events back into the gateway.
- `node.pluginTools.update` replaces the connected node's agent-visible plugin/MCP tool descriptors.
- `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs.
- `node.pending.enqueue` and `node.pending.drain` manage durable pending work for offline/disconnected nodes.

View File

@@ -120,10 +120,11 @@ Use [`defineToolPlugin`](/plugins/tool-plugins) for simple tool-only plugins
with fixed tool names. Use `api.registerTool(...)` directly for mixed plugins
or fully dynamic tool registration.
| Method | What it registers |
| ------------------------------- | --------------------------------------------- |
| `api.registerTool(tool, opts?)` | Agent tool (required or `{ optional: true }`) |
| `api.registerCommand(def)` | Custom command (bypasses the LLM) |
| Method | What it registers |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `api.registerTool(tool, opts?)` | Agent tool (required or `{ optional: true }`) |
| `api.registerCommand(def)` | Custom command (bypasses the LLM) |
| `api.registerNodeHostCommand(command)` | Command handled by `openclaw node run`; optional `agentTool` metadata can expose it as an agent-visible tool while the node is connected |
Plugin commands can set `agentPromptGuidance` when the agent needs a short,
command-owned routing hint. Keep that text about the command itself; do not add
@@ -150,6 +151,19 @@ surfaces: only guidance explicitly scoped to `codex_app_server` is promoted into
that higher-priority lane. Legacy string guidance and unscoped structured
guidance remain available to non-Codex prompt surfaces for compatibility.
Node-host commands run on the connected node host, not inside the Gateway
process. If `agentTool` is present, the node publishes a descriptor after a
successful Gateway connect; the Gateway exposes it to agent runs only while that
node is connected and only if the descriptor's `command` is in the node's
approved command surface. Set `agentTool.defaultPlatforms` to opt a
non-dangerous command into the default node command allowlist; otherwise require
explicit `gateway.nodes.allowCommands` or a node-invoke policy. `agentTool.name`
must be provider-safe: start with a letter, use only letters, digits,
underscores, or hyphens, and stay within 64 characters. MCP-backed node tools
can set `agentTool.mcp` metadata so catalog and tool-search surfaces can show
the remote MCP server/tool identity, but execution still goes through the
advertised node command.
### Infrastructure
| Method | What it registers |

View File

@@ -251,9 +251,15 @@ two-party event loops that do not go through the shared inbound reply runner.
});
```
`nodes.list(...)` includes each connected node's advertised
`nodePluginTools` descriptors when that node exposes plugin or MCP-backed
tools to the agent. Those descriptors are live connection state: the Gateway
drops them when the node disconnects, and a node can replace them with
`node.pluginTools.update` after local plugin/MCP inventory changes.
Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling.
Plugins that expose dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`. The policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls and higher-level plugin tools share the same enforcement path.
Plugins that expose node-hosted agent tools can set `agentTool.defaultPlatforms` for non-dangerous commands that should be allowlisted by default. Omit it when operators must opt in with `gateway.nodes.allowCommands`. Dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`; the policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls, node-hosted plugin tools, and higher-level plugin tools share the same enforcement path.
</Accordion>
<Accordion title="api.runtime.tasks.managedFlows">

View File

@@ -13,6 +13,7 @@ import {
validateModelsListParams,
validateNodeEventResult,
validateNodePairRequestParams,
validateNodePluginToolsUpdateParams,
validateNodePresenceAlivePayload,
validateTasksCancelParams,
validateTasksListParams,
@@ -84,6 +85,34 @@ describe("lazy protocol validators", () => {
expect(validateConnectParams.errors).toBeNull();
});
it("rejects provider-unsafe node plugin tool names", () => {
expect(
validateNodePluginToolsUpdateParams({
tools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Echo through a node",
command: "demo.echo",
},
],
}),
).toBe(true);
expect(
validateNodePluginToolsUpdateParams({
tools: [
{
pluginId: "demo",
name: "demo.echo",
description: "Invalid tool name",
command: "demo.echo",
},
],
}),
).toBe(false);
});
it("accepts selected-agent scope on chat send, history, and abort params", () => {
expect(
validateChatHistoryParams({

View File

@@ -277,6 +277,10 @@ import {
NodePairRequestParamsSchema,
type NodePairVerifyParams,
NodePairVerifyParamsSchema,
type NodePluginToolDescriptor,
NodePluginToolDescriptorSchema,
type NodePluginToolsUpdateParams,
NodePluginToolsUpdateParamsSchema,
type NodeRenameParams,
NodeRenameParamsSchema,
type PollParams,
@@ -569,6 +573,9 @@ export const validateNodePairVerifyParams = lazyCompile<NodePairVerifyParams>(
);
export const validateNodeRenameParams = lazyCompile<NodeRenameParams>(NodeRenameParamsSchema);
export const validateNodeListParams = lazyCompile<NodeListParams>(NodeListParamsSchema);
export const validateNodePluginToolsUpdateParams = lazyCompile<NodePluginToolsUpdateParams>(
NodePluginToolsUpdateParamsSchema,
);
export const validateEnvironmentsListParams = lazyCompile<EnvironmentsListParams>(
EnvironmentsListParamsSchema,
);
@@ -985,6 +992,8 @@ export {
NodePairRemoveParamsSchema,
NodePairVerifyParamsSchema,
NodeListParamsSchema,
NodePluginToolDescriptorSchema,
NodePluginToolsUpdateParamsSchema,
NodePendingAckParamsSchema,
NodeInvokeParamsSchema,
NodeEventResultSchema,
@@ -1301,6 +1310,8 @@ export type {
NodePairRemoveParams,
NodePairVerifyParams,
NodeListParams,
NodePluginToolDescriptor,
NodePluginToolsUpdateParams,
NodeInvokeParams,
NodeInvokeResultParams,
NodeEventParams,

View File

@@ -1,4 +1,5 @@
import { Type } from "typebox";
import { NodePluginToolDescriptorSchema } from "./nodes.js";
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
@@ -45,6 +46,7 @@ export const ConnectParamsSchema = Type.Object(
),
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
commands: Type.Optional(Type.Array(NonEmptyString)),
nodePluginTools: Type.Optional(Type.Array(NodePluginToolDescriptorSchema)),
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
pathEnv: Type.Optional(Type.String()),
role: Type.Optional(NonEmptyString),

View File

@@ -1,6 +1,12 @@
import { Type } from "typebox";
import { NonEmptyString } from "./primitives.js";
const NodePluginToolNameSchema = Type.String({
minLength: 1,
maxLength: 64,
pattern: "^[A-Za-z][A-Za-z0-9_-]{0,63}$",
});
/** Pending node work classes that the gateway may queue for paired devices. */
const NodePendingWorkTypeSchema = Type.String({
enum: ["status.request", "location.request"],
@@ -105,6 +111,35 @@ export const NodeRenameParamsSchema = Type.Object(
/** Lists paired nodes known to the gateway. */
export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false });
/** Agent-visible tool descriptor advertised by a connected node. */
export const NodePluginToolDescriptorSchema = Type.Object(
{
pluginId: NonEmptyString,
name: NodePluginToolNameSchema,
description: NonEmptyString,
parameters: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
command: Type.Optional(NonEmptyString),
mcp: Type.Optional(
Type.Object(
{
server: NonEmptyString,
tool: NonEmptyString,
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
/** Replaces the connected node's dynamic agent-visible plugin/MCP tool catalog. */
export const NodePluginToolsUpdateParamsSchema = Type.Object(
{
tools: Type.Array(NodePluginToolDescriptorSchema),
},
{ additionalProperties: false },
);
/** Acknowledges queued node work that the node has consumed. */
export const NodePendingAckParamsSchema = Type.Object(
{

View File

@@ -229,6 +229,8 @@ import {
NodePairRejectParamsSchema,
NodePairRequestParamsSchema,
NodePairVerifyParamsSchema,
NodePluginToolDescriptorSchema,
NodePluginToolsUpdateParamsSchema,
NodeRenameParamsSchema,
} from "./nodes.js";
import {
@@ -341,6 +343,8 @@ export const ProtocolSchemas = {
NodePairVerifyParams: NodePairVerifyParamsSchema,
NodeRenameParams: NodeRenameParamsSchema,
NodeListParams: NodeListParamsSchema,
NodePluginToolDescriptor: NodePluginToolDescriptorSchema,
NodePluginToolsUpdateParams: NodePluginToolsUpdateParamsSchema,
NodePendingAckParams: NodePendingAckParamsSchema,
NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema,

View File

@@ -50,6 +50,8 @@ export type NodePairRemoveParams = SchemaType<"NodePairRemoveParams">;
export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">;
export type NodeRenameParams = SchemaType<"NodeRenameParams">;
export type NodeListParams = SchemaType<"NodeListParams">;
export type NodePluginToolDescriptor = SchemaType<"NodePluginToolDescriptor">;
export type NodePluginToolsUpdateParams = SchemaType<"NodePluginToolsUpdateParams">;
export type NodePendingAckParams = SchemaType<"NodePendingAckParams">;
export type NodeDescribeParams = SchemaType<"NodeDescribeParams">;
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;

View File

@@ -0,0 +1,753 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
const DEFAULT_PORT = 18789;
const SESSION_KEY = "agent:main:main";
const PLUGIN_ID = "pond-node-tools";
let verboseOutput = false;
function parseArgs(argv) {
const args = { _: [] };
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value.startsWith("--")) {
args._.push(value);
continue;
}
const key = value.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function requireWebSocket() {
if (typeof WebSocket !== "function") {
throw new Error("Node global WebSocket unavailable; run with Node 22+");
}
}
function repoRoot() {
return path.resolve(import.meta.dirname, "..", "..");
}
function now() {
return Date.now();
}
function proofToken() {
return `pond-proof-${crypto.randomBytes(12).toString("hex")}`;
}
async function writeJson(filePath, value, options = {}) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const data = `${JSON.stringify(value, null, 2)}\n`;
if (typeof options.mode === "number") {
await fs.writeFile(filePath, data, { encoding: "utf8", mode: options.mode, flag: "w" });
await fs.chmod(filePath, options.mode);
return;
}
await fs.writeFile(filePath, data, "utf8");
}
async function writeProofPlugin(rootDir, nodeLabel) {
const pluginDir = path.join(rootDir, "plugin");
const pluginPath = path.join(pluginDir, "pond-node-tools.mjs");
await fs.mkdir(pluginDir, { recursive: true });
await writeJson(path.join(pluginDir, "openclaw.plugin.json"), {
id: PLUGIN_ID,
name: "Pond Node Tools",
description: "Node-hosted plugin tool proof",
activation: { onStartup: true },
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
});
await writeJson(path.join(pluginDir, "package.json"), {
name: PLUGIN_ID,
version: "0.0.0",
type: "module",
openclaw: { extensions: ["./pond-node-tools.mjs"] },
});
const source = `
import os from "node:os";
const nodeLabel = process.env.OPENCLAW_POND_NODE_LABEL || ${JSON.stringify(nodeLabel)};
function readParams(paramsJSON) {
if (!paramsJSON) return {};
try {
const parsed = JSON.parse(paramsJSON);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
export default {
id: ${JSON.stringify(PLUGIN_ID)},
name: "Pond Node Tools",
description: "Node-hosted plugin tool proof",
register(api) {
api.registerNodeHostCommand({
command: "pond.echo",
agentTool: {
name: "pond_echo",
description: "Echo proof payload from the connected node host.",
parameters: {
type: "object",
properties: {
message: { type: "string" }
},
required: ["message"],
additionalProperties: false
},
defaultPlatforms: ["linux", "macos"],
mcp: { server: "pond-proof", tool: "echo" }
},
handle: async (paramsJSON) =>
JSON.stringify({
ok: true,
nodeLabel,
hostname: os.hostname(),
params: readParams(paramsJSON)
})
});
}
};
`.trimStart();
await fs.writeFile(pluginPath, source, "utf8");
return pluginDir;
}
async function prepareRoleState(baseDir, role, token, nodeLabel) {
const rootDir = path.resolve(baseDir, role);
const stateDir = path.join(rootDir, "state");
const configPath = path.join(rootDir, "openclaw.json");
await fs.mkdir(rootDir, { recursive: true, mode: 0o700 });
await fs.chmod(rootDir, 0o700);
const pluginPath = await writeProofPlugin(rootDir, nodeLabel);
await writeJson(
configPath,
{
gateway: {
mode: "local",
bind: "lan",
auth: { mode: "token", token },
nodes: { allowCommands: ["pond.echo"] },
},
plugins: {
load: { paths: [pluginPath] },
entries: { [PLUGIN_ID]: { enabled: true } },
},
},
{ mode: 0o600 },
);
await writeJson(path.join(stateDir, "agents", "main", "sessions", "sessions.json"), {
[SESSION_KEY]: {
sessionId: "pond-proof-main",
updatedAt: now(),
modelProvider: "openai",
model: "gpt-5.5",
},
});
return { rootDir, stateDir, configPath, pluginPath };
}
function childEnv(state, token, nodeLabel) {
return {
...process.env,
OPENCLAW_CONFIG_PATH: state.configPath,
OPENCLAW_STATE_DIR: state.stateDir,
OPENCLAW_GATEWAY_TOKEN: token,
OPENCLAW_POND_NODE_LABEL: nodeLabel,
};
}
function spawnOpenClaw(args, options) {
const cliArgs = options.built ? ["openclaw.mjs", ...args] : ["scripts/run-node.mjs", ...args];
const child = spawn("node", cliArgs, {
cwd: repoRoot(),
env: options.env,
stdio: options.stdio ?? "inherit",
});
child.on("exit", (code, signal) => {
if (options.onExit) {
options.onExit(code, signal);
}
});
return child;
}
async function runCommand(command, args, options = {}) {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot(),
env: options.env ?? process.env,
stdio: options.stdio ?? "inherit",
});
await waitForChild(child);
}
function waitForChild(child) {
return new Promise((resolve, reject) => {
child.once("exit", (code, signal) => {
if (code === 0 || signal) {
resolve({ code, signal });
return;
}
reject(new Error(`child exited with code ${code}`));
});
});
}
async function runForegroundChild(child) {
const forward = (signal) => {
if (child.exitCode === null) {
child.kill(signal);
}
};
process.once("SIGTERM", forward);
process.once("SIGINT", forward);
try {
await waitForChild(child);
} finally {
process.off("SIGTERM", forward);
process.off("SIGINT", forward);
}
}
function terminate(child) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null) {
resolve();
return;
}
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 5_000);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
child.kill("SIGTERM");
});
}
class GatewayRpc {
constructor({ url, token, scopes }) {
requireWebSocket();
this.url = url;
this.token = token;
this.scopes = scopes;
this.pending = new Map();
this.nextId = 1;
}
async connect() {
this.ws = new WebSocket(this.url);
await new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Gateway connect timeout: ${this.url}`)),
15_000,
);
this.ws.addEventListener("open", () => {
clearTimeout(timer);
resolve();
});
this.ws.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error(`Gateway socket error: ${this.url}`));
});
});
this.ws.addEventListener("message", (event) => this.onMessage(event));
await this.request("connect", {
minProtocol: 1,
maxProtocol: 99,
client: {
id: "gateway-client",
displayName: "Pond proof verifier",
version: "0.0.0",
platform: process.platform,
mode: "backend",
},
auth: { token: this.token },
role: "operator",
scopes: this.scopes,
});
}
onMessage(event) {
let frame;
try {
frame = JSON.parse(String(event.data));
} catch {
return;
}
if (frame?.type !== "res" || typeof frame.id !== "string") {
return;
}
const pending = this.pending.get(frame.id);
if (!pending) {
return;
}
if (pending.expectFinal && frame.payload?.status === "accepted") {
return;
}
this.pending.delete(frame.id);
clearTimeout(pending.timer);
if (frame.ok) {
pending.resolve(frame.payload);
return;
}
pending.reject(new Error(frame.error?.message ?? `Gateway RPC failed: ${pending.method}`));
}
request(method, params = {}, options = {}) {
const id = `pond-proof-${this.nextId}`;
this.nextId += 1;
const timeoutMs = options.timeoutMs ?? 30_000;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Gateway RPC timeout: ${method}`));
}, timeoutMs);
this.pending.set(id, {
method,
expectFinal: options.expectFinal === true,
resolve,
reject,
timer,
});
this.ws.send(JSON.stringify({ type: "req", id, method, params }));
});
}
close() {
this.ws?.close();
}
}
async function connectVerifier(url, token) {
const rpc = new GatewayRpc({
url,
token,
scopes: ["operator.read", "operator.write", "operator.pairing", "operator.admin"],
});
await rpc.connect();
return rpc;
}
async function waitFor(label, timeoutMs, fn) {
const deadline = now() + timeoutMs;
let lastError;
while (now() < deadline) {
try {
const value = await fn();
if (value) {
return value;
}
} catch (err) {
lastError = err;
}
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
}
throw new Error(`${label} timed out${lastError ? `: ${lastError.message}` : ""}`);
}
function connectedProofNodes(nodes) {
return (nodes ?? []).filter(
(node) =>
Array.isArray(node.nodePluginTools) &&
node.nodePluginTools.some((tool) => tool.pluginId === PLUGIN_ID && tool.name === "pond_echo"),
);
}
function isPondPairingRequest(request) {
const commands = Array.isArray(request?.commands) ? request.commands : [];
const nodeId = typeof request?.nodeId === "string" ? request.nodeId : "";
const displayName = typeof request?.displayName === "string" ? request.displayName : "";
return (
commands.includes("pond.echo") &&
(nodeId.startsWith("pond-") || displayName.startsWith("Pond "))
);
}
async function approvePendingNodes(rpc) {
const list = await rpc.request("node.pair.list", {});
const pending = Array.isArray(list?.pending) ? list.pending : [];
for (const request of pending) {
if (request?.requestId && isPondPairingRequest(request)) {
await rpc.request("node.pair.approve", { requestId: request.requestId });
}
}
return pending.filter(isPondPairingRequest).length;
}
async function waitForProofNodes(rpc, count) {
let lastLogMs = 0;
return await waitFor(`connected proof nodes >= ${count}`, 60_000, async () => {
await approvePendingNodes(rpc);
const result = await rpc.request("node.list", {});
const nodes = connectedProofNodes(result?.nodes);
if (verboseOutput && now() - lastLogMs > 5_000) {
lastLogMs = now();
console.error(
"[pond-proof] node.list",
JSON.stringify(
(result?.nodes ?? []).map((node) => ({
nodeId: node.nodeId,
displayName: node.displayName,
status: node.status,
connected: node.connected,
commands: node.commands,
nodePluginTools: node.nodePluginTools,
})),
),
);
}
return nodes.length >= count ? nodes : null;
});
}
function flattenEffectiveTools(result) {
return (result?.groups ?? []).flatMap((group) =>
(group.tools ?? []).map((tool) => Object.assign({}, tool, { groupId: group.id })),
);
}
async function readEffectiveProofTools(rpc) {
const result = await rpc.request("tools.effective", { sessionKey: SESSION_KEY });
return flattenEffectiveTools(result).filter(
(tool) => tool.pluginId === PLUGIN_ID && tool.id.startsWith("pond_echo"),
);
}
async function invokeProofTools(rpc, tools) {
const outputs = [];
for (const tool of tools) {
const result = await rpc.request(
"tools.invoke",
{
name: tool.id,
sessionKey: SESSION_KEY,
args: { message: `from-${tool.id}` },
idempotencyKey: `pond-${tool.id}-${now()}`,
},
{ timeoutMs: 45_000 },
);
if (!result?.ok) {
throw new Error(`tools.invoke failed for ${tool.id}: ${JSON.stringify(result)}`);
}
outputs.push({ tool: tool.id, output: result.output?.details ?? result.output });
}
return outputs;
}
async function runVerify({ url, token, expectedNodes }) {
const rpc = await connectVerifier(url, token);
try {
const nodes = await waitForProofNodes(rpc, expectedNodes);
const tools = await waitFor(`effective proof tools >= ${expectedNodes}`, 30_000, async () => {
const value = await readEffectiveProofTools(rpc);
return value.length >= expectedNodes ? value : null;
});
const outputs = await invokeProofTools(rpc, tools);
const labels = new Set(outputs.map((entry) => entry.output?.nodeLabel).filter(Boolean));
if (labels.size < expectedNodes) {
throw new Error(`expected ${expectedNodes} node labels, got ${[...labels].join(",")}`);
}
console.log(
JSON.stringify(
{
ok: true,
nodes: nodes.map((node) => ({
nodeId: node.nodeId,
displayName: node.displayName,
tools: node.nodePluginTools,
})),
effectiveTools: tools,
outputs,
},
null,
2,
),
);
} finally {
rpc.close();
}
}
async function runGateway(args) {
const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || "");
if (!token) {
throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required");
}
const port = Number(args.port || DEFAULT_PORT);
const baseDir = String(
args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))),
);
const state = await prepareRoleState(baseDir, "gateway", token, "gateway");
console.log(JSON.stringify({ role: "gateway", port, tokenSet: true, stateDir: state.stateDir }));
const child = spawnOpenClaw(
[
"gateway",
"run",
"--allow-unconfigured",
"--auth",
"token",
"--bind",
"lan",
"--port",
String(port),
"--ws-log",
"compact",
],
{ env: childEnv(state, token, "gateway") },
);
await runForegroundChild(child);
}
async function runNode(args) {
const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || "");
if (!token) {
throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required");
}
const host = String(args.host || "127.0.0.1");
const port = Number(args.port || DEFAULT_PORT);
const nodeId = String(args.nodeId || `pond-${crypto.randomBytes(4).toString("hex")}`);
const displayName = String(args.displayName || nodeId);
const baseDir = String(
args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))),
);
const state = await prepareRoleState(baseDir, nodeId, token, nodeId);
console.log(JSON.stringify({ role: "node", nodeId, host, port, tokenSet: true }));
const child = spawnOpenClaw(
[
"node",
"run",
"--host",
host,
"--port",
String(port),
"--node-id",
nodeId,
"--display-name",
displayName,
],
{ env: childEnv(state, token, nodeId) },
);
if (args.lifetimeMs) {
setTimeout(() => {
child.kill("SIGTERM");
}, Number(args.lifetimeMs));
}
await runForegroundChild(child);
}
async function runLocal(args) {
const token = String(args.token || proofToken());
const port = Number(args.port || DEFAULT_PORT);
const baseDir = String(
args.baseDir || (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-plugin-tools-"))),
);
const gatewayState = await prepareRoleState(baseDir, "gateway", token, "gateway");
const nodeAState = await prepareRoleState(baseDir, "pond-a", token, "pond-a");
const nodeBState = await prepareRoleState(baseDir, "pond-b", token, "pond-b");
const children = [];
if (!args.skipBuild) {
await runCommand("pnpm", ["build"], {
stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"],
});
}
const childOptions = (state, label) => ({
env: childEnv(state, token, label),
stdio: args.verbose ? "inherit" : ["ignore", "ignore", "ignore"],
built: true,
onExit: (code, signal) => {
if (args.verbose) {
console.error(`[${label}] exit code=${code} signal=${signal}`);
}
},
});
try {
children.push(
spawnOpenClaw(
[
"gateway",
"run",
"--allow-unconfigured",
"--auth",
"token",
"--bind",
"loopback",
"--port",
String(port),
"--ws-log",
"compact",
],
childOptions(gatewayState, "gateway"),
),
);
const url = `ws://127.0.0.1:${port}`;
await waitFor("gateway RPC", 60_000, async () => {
const rpc = await connectVerifier(url, token);
rpc.close();
return true;
});
children.push(
spawnOpenClaw(
[
"node",
"run",
"--host",
"127.0.0.1",
"--port",
String(port),
"--node-id",
"pond-a",
"--display-name",
"Pond A",
],
childOptions(nodeAState, "pond-a"),
),
);
children.push(
spawnOpenClaw(
[
"node",
"run",
"--host",
"127.0.0.1",
"--port",
String(port),
"--node-id",
"pond-b",
"--display-name",
"Pond B",
],
childOptions(nodeBState, "pond-b"),
),
);
const rpc = await connectVerifier(url, token);
try {
await waitForProofNodes(rpc, 2);
const initialTools = await waitFor("two effective proof tools", 30_000, async () => {
const tools = await readEffectiveProofTools(rpc);
return tools.length === 2 ? tools : null;
});
const initialOutputs = await invokeProofTools(rpc, initialTools);
await terminate(children.pop());
await waitFor("pond-b offline", 30_000, async () => {
const result = await rpc.request("node.list", {});
return connectedProofNodes(result?.nodes).length === 1;
});
const afterOfflineTools = await waitFor(
"one effective proof tool after offline",
30_000,
async () => {
const tools = await readEffectiveProofTools(rpc);
return tools.length === 1 ? tools : null;
},
);
const restartedB = spawnOpenClaw(
[
"node",
"run",
"--host",
"127.0.0.1",
"--port",
String(port),
"--node-id",
"pond-b",
"--display-name",
"Pond B",
],
childOptions(nodeBState, "pond-b-restart"),
);
children.push(restartedB);
await waitForProofNodes(rpc, 2);
const afterReconnectTools = await waitFor(
"two effective proof tools after reconnect",
30_000,
async () => {
const tools = await readEffectiveProofTools(rpc);
return tools.length === 2 ? tools : null;
},
);
const afterReconnectOutputs = await invokeProofTools(rpc, afterReconnectTools);
console.log(
JSON.stringify(
{
ok: true,
provider: "local-process",
baseDir,
initialTools,
initialOutputs,
afterOfflineTools,
afterReconnectTools,
afterReconnectOutputs,
},
null,
2,
),
);
} finally {
rpc.close();
}
} finally {
await Promise.all(children.map((child) => terminate(child)));
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
verboseOutput = args.verbose === true;
const mode = args._[0];
if (mode === "gateway") {
await runGateway(args);
return;
}
if (mode === "node") {
await runNode(args);
return;
}
if (mode === "verify") {
const token = String(args.token || process.env.OPENCLAW_GATEWAY_TOKEN || "");
if (!token) {
throw new Error("--token or OPENCLAW_GATEWAY_TOKEN required");
}
await runVerify({
url: String(args.url || `ws://127.0.0.1:${args.port || DEFAULT_PORT}`),
token,
expectedNodes: Number(args.expectedNodes || 2),
});
return;
}
if (mode === "local") {
await runLocal(args);
return;
}
throw new Error("usage: node scripts/e2e/node-plugin-tools-pond.mjs <local|gateway|node|verify>");
}
main().catch(
/** @param {unknown} err */ (err) => {
console.error(err instanceof Error ? err.stack || err.message : String(err));
process.exitCode = 1;
},
);

View File

@@ -0,0 +1,223 @@
/** Tests connected node-hosted plugin tool materialization. */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
replaceConnectedNodePluginTools,
resetConnectedNodePluginToolsForTest,
} from "../gateway/node-plugin-tool-snapshot.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { createNodePluginTools } from "./node-plugin-tools.js";
import { callGatewayTool } from "./tools/gateway.js";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(),
}));
afterEach(() => {
resetConnectedNodePluginToolsForTest();
vi.mocked(callGatewayTool).mockReset();
});
describe("createNodePluginTools", () => {
it("materializes connected node plugin tools and invokes their node command", async () => {
replaceConnectedNodePluginTools({
nodeId: "node-1",
displayName: "Studio Node",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
parameters: {
type: "object",
properties: { text: { type: "string" } },
},
command: "remote.echo",
mcp: {
server: "remote-demo",
tool: "echo",
},
},
],
});
vi.mocked(callGatewayTool).mockResolvedValueOnce({
payload: {
content: [{ type: "text", text: "pong" }],
details: { ok: true },
},
});
const tools = createNodePluginTools({ existingToolNames: new Set(["read"]) });
const result = await tools[0].execute("call-1", { text: "ping" });
expect(tools.map((tool) => tool.name)).toEqual(["remote_echo"]);
expect(tools[0].description).toContain("Studio Node");
expect(getPluginToolMeta(tools[0])).toMatchObject({
pluginId: "remote-demo",
mcp: {
serverName: "remote-demo",
toolName: "echo",
operation: "tool",
},
});
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{},
{
nodeId: "node-1",
command: "remote.echo",
params: { text: "ping" },
idempotencyKey: "call-1",
},
{ scopes: ["operator.write"] },
);
expect(result.content).toEqual([{ type: "text", text: "pong" }]);
});
it("disambiguates node tools that collide with existing tool names", () => {
replaceConnectedNodePluginTools({
nodeId: "node-1",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
expect(
createNodePluginTools({ existingToolNames: new Set(["remote_echo"]) }).map(
(tool) => tool.name,
),
).toEqual(["remote_echo_node_1"]);
});
it("disambiguates matching tool names from different nodes", async () => {
replaceConnectedNodePluginTools({
nodeId: "node-a",
displayName: "Node A",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
replaceConnectedNodePluginTools({
nodeId: "node-b",
displayName: "Node B",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
vi.mocked(callGatewayTool).mockResolvedValueOnce({
payload: { ok: true, node: "b" },
});
const tools = createNodePluginTools({});
const result = await tools[1].execute("call-2", { text: "ping" });
expect(tools.map((tool) => tool.name)).toEqual(["remote_echo_node_a", "remote_echo_node_b"]);
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{},
{
nodeId: "node-b",
command: "remote.echo",
params: { text: "ping" },
idempotencyKey: "call-2",
},
{ scopes: ["operator.write"] },
);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining('"node": "b"'),
});
});
it("honors policy for disambiguated node tool names", () => {
for (const nodeId of ["node-a", "node-b"]) {
replaceConnectedNodePluginTools({
nodeId,
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
}
expect(
createNodePluginTools({
toolAllowlist: ["remote_echo_node_b"],
}).map((tool) => tool.name),
).toEqual(["remote_echo_node_b"]);
expect(
createNodePluginTools({
toolDenylist: ["remote_echo_node_b"],
}).map((tool) => tool.name),
).toEqual(["remote_echo_node_a"]);
});
it("keeps disambiguated node tool names provider-safe", () => {
const longName = `a${"b".repeat(63)}`;
for (const nodeId of ["node-a", "node-b"]) {
replaceConnectedNodePluginTools({
nodeId,
tools: [
{
pluginId: "remote-demo",
name: longName,
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
}
const names = createNodePluginTools({}).map((tool) => tool.name);
expect(names).toHaveLength(2);
expect(names.every((name) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name))).toBe(true);
expect(names[0]).not.toBe(names[1]);
});
it("honors plugin tool allow and deny policy", () => {
replaceConnectedNodePluginTools({
nodeId: "node-1",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
{
pluginId: "remote-demo",
name: "remote_status",
description: "Read remote status",
command: "remote.status",
},
],
});
expect(
createNodePluginTools({
toolAllowlist: ["remote-demo"],
toolDenylist: ["remote_status"],
}).map((tool) => tool.name),
).toEqual(["remote_echo"]);
expect(createNodePluginTools({ toolAllowlist: ["other-plugin"] })).toEqual([]);
});
});

View File

@@ -0,0 +1,214 @@
/** Materializes connected node-hosted plugin tools for agent runs. */
import { listConnectedNodePluginTools } from "../gateway/node-plugin-tool-snapshot.js";
import { setPluginToolMeta } from "../plugins/tools.js";
import { sanitizeServerName } from "./agent-bundle-mcp-names.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import type { AgentToolResult } from "./runtime/index.js";
import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "./tool-policy.js";
import { jsonResult } from "./tools/common.js";
import type { AnyAgentTool } from "./tools/common.js";
import { callGatewayTool } from "./tools/gateway.js";
const NODE_PLUGIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
const NODE_PLUGIN_TOOL_NAME_MAX_LENGTH = 64;
type MaterializedNodeToolEntry = ReturnType<typeof listConnectedNodePluginTools>[number] & {
command: string;
normalizedName: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isAgentToolResult(value: unknown): value is AgentToolResult<unknown> {
return isRecord(value) && Array.isArray(value.content);
}
function readNodeInvokePayload(value: unknown): unknown {
return isRecord(value) && "payload" in value ? value.payload : value;
}
function normalizePolicyNames(values: readonly string[] | undefined): Set<string> {
return new Set((values ?? []).map((value) => normalizeToolName(value)).filter(Boolean));
}
function toolPolicyAllows(params: {
pluginId: string;
toolName: string;
exposedToolName?: string;
allowlist: Set<string>;
denylist: ReturnType<typeof compileGlobPatterns>;
}): boolean {
const pluginId = normalizeToolName(params.pluginId);
const toolName = normalizeToolName(params.toolName);
const exposedToolName = normalizeToolName(params.exposedToolName ?? params.toolName);
if (
matchesAnyGlobPattern(pluginId, params.denylist) ||
matchesAnyGlobPattern(toolName, params.denylist) ||
matchesAnyGlobPattern(exposedToolName, params.denylist) ||
matchesAnyGlobPattern("group:plugins", params.denylist)
) {
return false;
}
if (params.allowlist.size === 0 || params.allowlist.has(DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY)) {
return true;
}
return (
params.allowlist.has("*") ||
params.allowlist.has("group:plugins") ||
params.allowlist.has(pluginId) ||
params.allowlist.has(toolName) ||
params.allowlist.has(exposedToolName)
);
}
function describeNodeToolLocation(params: {
description: string;
displayName?: string;
nodeId: string;
}): string {
const label = params.displayName?.trim() || params.nodeId;
return `${params.description} (node: ${label})`;
}
function sanitizeToolNameFragment(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 32);
}
function isProviderSafeToolName(value: string): boolean {
return NODE_PLUGIN_TOOL_NAME_RE.test(value);
}
function appendToolNameSuffix(baseName: string, suffix: string): string {
const maxBaseLength = Math.max(1, NODE_PLUGIN_TOOL_NAME_MAX_LENGTH - suffix.length);
return `${baseName.slice(0, maxBaseLength)}${suffix}`;
}
function resolveUniqueToolName(params: {
baseName: string;
normalizedName: string;
duplicateCount: number;
nodeId: string;
existingNormalized: Set<string>;
}): string | null {
if (params.duplicateCount === 1 && !params.existingNormalized.has(params.normalizedName)) {
return params.baseName;
}
const nodeFragment = sanitizeToolNameFragment(params.nodeId);
const nodeSuffix = nodeFragment ? `_${nodeFragment}` : "_node";
const stem = appendToolNameSuffix(params.baseName, nodeSuffix);
for (let index = 0; index < 100; index += 1) {
const suffix = index === 0 ? "" : `_${index + 1}`;
const candidate = suffix ? appendToolNameSuffix(stem, suffix) : stem;
const normalized = normalizeToolName(candidate);
if (
isProviderSafeToolName(candidate) &&
normalized &&
!params.existingNormalized.has(normalized)
) {
return candidate;
}
}
return null;
}
export function createNodePluginTools(params: {
existingToolNames?: Set<string>;
toolAllowlist?: string[];
toolDenylist?: string[];
}): AnyAgentTool[] {
const existingNormalized = new Set(
[...(params.existingToolNames ?? [])].map((name) => normalizeToolName(name)),
);
const allowlist = normalizePolicyNames(params.toolAllowlist);
const denylist = compileGlobPatterns({
raw: params.toolDenylist,
normalize: normalizeToolName,
});
const entries: MaterializedNodeToolEntry[] = [];
const nameCounts = new Map<string, number>();
for (const entry of listConnectedNodePluginTools()) {
const descriptor = entry.descriptor;
const command = descriptor.command?.trim();
const normalizedName = normalizeToolName(descriptor.name);
if (!command || !normalizedName) {
continue;
}
entries.push({ ...entry, command, normalizedName });
nameCounts.set(normalizedName, (nameCounts.get(normalizedName) ?? 0) + 1);
}
const tools: AnyAgentTool[] = [];
for (const entry of entries) {
const descriptor = entry.descriptor;
const toolName = resolveUniqueToolName({
baseName: descriptor.name,
normalizedName: entry.normalizedName,
duplicateCount: nameCounts.get(entry.normalizedName) ?? 1,
nodeId: entry.nodeId,
existingNormalized,
});
if (!toolName) {
continue;
}
if (
!toolPolicyAllows({
pluginId: descriptor.pluginId,
toolName: descriptor.name,
exposedToolName: toolName,
allowlist,
denylist,
})
) {
continue;
}
existingNormalized.add(normalizeToolName(toolName));
const tool: AnyAgentTool = {
name: toolName,
label: toolName,
description: describeNodeToolLocation({
description: descriptor.description,
displayName: entry.displayName,
nodeId: entry.nodeId,
}),
parameters: descriptor.parameters as never,
execute: async (toolCallId, toolParams) => {
const raw = await callGatewayTool(
"node.invoke",
{},
{
nodeId: entry.nodeId,
command: entry.command,
params: toolParams,
idempotencyKey: toolCallId,
},
{ scopes: ["operator.write"] },
);
const payload = readNodeInvokePayload(raw);
return isAgentToolResult(payload) ? payload : jsonResult(payload);
},
};
setPluginToolMeta(tool, {
pluginId: descriptor.pluginId,
optional: false,
...(descriptor.mcp
? {
mcp: {
serverName: descriptor.mcp.server,
safeServerName: sanitizeServerName(descriptor.mcp.server, new Set<string>()),
toolName: descriptor.mcp.tool,
operation: "tool",
},
}
: {}),
});
tools.push(tool);
}
return tools;
}

View File

@@ -14,6 +14,7 @@ import { resolvePluginTools } from "../plugins/tools.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { resolveApiKeyForProfile, resolveAuthProfileOrder } from "./auth-profiles.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import { createNodePluginTools } from "./node-plugin-tools.js";
import {
resolveOpenClawPluginToolInputs,
type OpenClawPluginToolOptions,
@@ -116,6 +117,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
runtimeConfig: resolveCurrentRuntimeConfig(),
getRuntimeConfig: resolveCurrentRuntimeConfig,
});
const existingToolNames = new Set(params.existingToolNames ?? []);
const pluginTools = resolvePluginTools({
...pluginToolInputs,
context: {
@@ -123,12 +125,22 @@ export function resolveOpenClawPluginToolsForOptions(params: {
...(hasAuthForProvider ? { hasAuthForProvider } : {}),
...(resolveApiKeyForProvider ? { resolveApiKeyForProvider } : {}),
},
existingToolNames: params.existingToolNames ?? new Set<string>(),
existingToolNames,
toolAllowlist: params.options?.pluginToolAllowlist,
toolDenylist: params.options?.pluginToolDenylist,
allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding,
...(hasAuthForProvider ? { hasAuthForProvider } : {}),
});
for (const tool of pluginTools) {
existingToolNames.add(tool.name);
}
pluginTools.push(
...createNodePluginTools({
existingToolNames,
toolAllowlist: params.options?.pluginToolAllowlist,
toolDenylist: params.options?.pluginToolDenylist,
}),
);
return applyPluginToolDeliveryDefaults({
tools: pluginTools,

View File

@@ -46,6 +46,21 @@ function pluginTool(name: string, description: string, pluginId = "fake-catalog"
return tool;
}
function mcpPluginTool(name: string, description: string, pluginId = "fake-catalog"): AnyAgentTool {
const tool = fakeTool(name, description);
setPluginToolMeta(tool, {
pluginId,
optional: true,
mcp: {
serverName: "remote-demo",
safeServerName: "remoteDemo",
toolName: "echo",
operation: "tool",
},
});
return tool;
}
function resultDetails(result: { details?: unknown }): Record<string, unknown> {
if (!result.details || typeof result.details !== "object") {
throw new Error("Expected result details");
@@ -446,6 +461,31 @@ describe("Tool Search", () => {
expect(thirdCall[4]).toBeUndefined();
});
it("classifies plugin tools with MCP metadata as MCP catalog entries", () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = mcpPluginTool("remote_echo", "Echo through remote MCP", "remote-demo");
applyToolSearchCatalog({
tools: [codeTool, target],
config: { tools: { toolSearch: true } } as never,
sessionId: "session-mcp-node",
});
const entry = testing.sessionCatalogs
.get("session:session-mcp-node")
?.entries.find((candidate) => candidate.name === "remote_echo");
expect(entry).toMatchObject({
id: "mcp:remoteDemo:remote_echo",
source: "mcp",
sourceName: "remoteDemo",
mcp: {
serverName: "remote-demo",
safeServerName: "remoteDemo",
toolName: "echo",
},
});
});
it("routes bridged calls through the configured catalog executor", async () => {
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_lifecycle", "Run through lifecycle executor");

View File

@@ -627,14 +627,17 @@ function classifyTool(tool: CatalogTool): {
} {
const meta = getPluginToolMeta(tool as AnyAgentTool);
const pluginId = meta?.pluginId?.trim();
if (pluginId === "bundle-mcp") {
const mcp = meta?.mcp;
const mcp = meta?.mcp;
if (mcp) {
return {
source: "mcp",
sourceName: pluginId,
...(mcp ? { mcp } : {}),
sourceName: mcp.safeServerName || pluginId || "mcp",
mcp,
};
}
if (pluginId === "bundle-mcp") {
return { source: "mcp", sourceName: pluginId };
}
if (pluginId) {
return { source: "openclaw", sourceName: pluginId };
}

View File

@@ -63,7 +63,7 @@ function resolveEffectiveToolSource(
const pluginMeta =
getPluginToolMeta(tool) ?? (fallbackTool ? getPluginToolMeta(fallbackTool) : undefined);
if (pluginMeta) {
if (pluginMeta.pluginId === "bundle-mcp") {
if (pluginMeta.mcp || pluginMeta.pluginId === "bundle-mcp") {
return { source: "mcp", pluginId: pluginMeta.pluginId };
}
return { source: "plugin", pluginId: pluginMeta.pluginId };

View File

@@ -8,6 +8,11 @@ import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { createOpenClawCodingTools } from "./agent-tools.js";
import type { AnyAgentTool } from "./tools/common.js";
type TestPluginMeta = Record<
string,
{ pluginId: string; mcp?: { serverName: string } } | undefined
>;
function mockTool(params: {
name: string;
label: string;
@@ -29,7 +34,7 @@ const effectiveInventoryState = vi.hoisted(() => ({
mockTool({ name: "exec", label: "Exec", description: "Run shell commands" }),
mockTool({ name: "docs_lookup", label: "Docs Lookup", description: "Search docs" }),
] as AnyAgentTool[],
pluginMeta: {} as Record<string, { pluginId: string } | undefined>,
pluginMeta: {} as TestPluginMeta,
channelMeta: {} as Record<string, { channelId: string } | undefined>,
effectivePolicy: {} as { profile?: string; providerProfile?: string },
normalizeToolsMock: vi.fn((options: { tools: AnyAgentTool[] }) => options.tools),
@@ -114,7 +119,7 @@ let resolveEffectiveToolInventory: typeof import("./tools-effective-inventory.js
async function loadHarness(options?: {
tools?: AnyAgentTool[];
createToolsMock?: typeof effectiveInventoryState.createToolsMock;
pluginMeta?: Record<string, { pluginId: string } | undefined>;
pluginMeta?: TestPluginMeta;
channelMeta?: Record<string, { channelId: string } | undefined>;
effectivePolicy?: { profile?: string; providerProfile?: string };
normalizeToolsMock?: typeof effectiveInventoryState.normalizeToolsMock;
@@ -263,6 +268,41 @@ describe("resolveEffectiveToolInventory", () => {
]);
});
it("groups plugin tools with MCP metadata separately from generic plugin tools", async () => {
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryLocal11 } =
await loadHarness({
tools: [
mockTool({ name: "remote_echo", label: "Remote Echo", description: "Probe node MCP" }),
],
pluginMeta: {
remote_echo: {
pluginId: "remote-demo",
mcp: { serverName: "remote-demo" },
},
},
});
const result = resolveEffectiveToolInventoryLocal11({ cfg: {} });
expect(result.groups).toEqual([
{
id: "mcp",
label: "MCP server tools",
source: "mcp",
tools: [
{
id: "remote_echo",
label: "Remote Echo",
description: "Probe node MCP",
rawDescription: "Probe node MCP",
source: "mcp",
pluginId: "remote-demo",
},
],
},
]);
});
it("disambiguates duplicate labels with source ids", async () => {
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryLocal9 } =
await loadHarness({

View File

@@ -180,7 +180,7 @@ describe("resolvePdfModelConfigForTool", () => {
} as OpenClawConfig;
expect(resolvePdfModelConfigForTool({ cfg, agentDir: TEST_AGENT_DIR })).toEqual({
primary: "openai/gpt-5.4-mini",
primary: "openai/gpt-5.5",
fallbacks: ["minimax/MiniMax-M2.7", "minimax-portal/MiniMax-M2.7"],
});
});

View File

@@ -31,6 +31,7 @@ export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
* A file is considered effectively empty if it contains only:
* - Whitespace / empty lines
* - Markdown ATX headers (`#`, `##`, ...)
* - One-line HTML comments (`<!-- ... -->`)
* - Markdown fence markers such as ``` or ```markdown
* - Empty list item stubs (`- `, `- [ ]`, `* `, `+ `)
*
@@ -58,6 +59,9 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
if (/^#+(\s|$)/.test(trimmed)) {
continue;
}
if (/^<!--.*-->$/.test(trimmed)) {
continue;
}
// Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- "
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) {
continue;

View File

@@ -208,6 +208,8 @@ Add short tasks below the comments only when you want the agent to check somethi
await expect(fs.readFile(heartbeatPath, "utf-8")).resolves.toBe(
`${[
"<!-- Heartbeat template; comments-only content prevents scheduled heartbeat API calls. -->",
"",
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
"",
"# Add tasks below when you want the agent to check something periodically.",

View File

@@ -992,6 +992,18 @@ describe("GatewayClient connect auth payload", () => {
client.stop();
});
it("does not advertise node plugin tools in the initial connect frame", () => {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
deviceIdentity: null,
});
const { connect } = startClientAndConnect({ client });
expect(connect.params).not.toHaveProperty("nodePluginTools");
client.stop();
});
function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") {
ws.emitMessage(
JSON.stringify({

View File

@@ -12,7 +12,11 @@ import type {
GatewayClientMode,
GatewayClientName,
} from "../../packages/gateway-protocol/src/client-info.js";
import type { EventFrame, HelloOk } from "../../packages/gateway-protocol/src/index.js";
import type {
EventFrame,
HelloOk,
NodePluginToolDescriptor,
} from "../../packages/gateway-protocol/src/index.js";
import {
clearDeviceAuthToken,
loadDeviceAuthToken,
@@ -135,6 +139,7 @@ export type GatewayClientOptions = {
scopes?: string[];
caps?: string[];
commands?: string[];
nodePluginTools?: NodePluginToolDescriptor[];
permissions?: Record<string, boolean>;
pathEnv?: string;
env?: NodeJS.ProcessEnv;

View File

@@ -377,6 +377,7 @@ describe("core gateway method classification", () => {
expect(isGatewayMethodClassified("node.pending.drain")).toBe(true);
expect(isGatewayMethodClassified("node.pending.pull")).toBe(true);
expect(isGatewayMethodClassified("node.pluginSurface.refresh")).toBe(true);
expect(isGatewayMethodClassified("node.pluginTools.update")).toBe(true);
});
it("classifies every exposed core gateway handler method", () => {

View File

@@ -176,6 +176,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "node.list", scope: "operator.read" },
{ name: "node.describe", scope: "operator.read" },
{ name: "node.pluginSurface.refresh", scope: "node" },
{ name: "node.pluginTools.update", scope: "node" },
{ name: "node.pending.drain", scope: "node" },
{ name: "node.pending.enqueue", scope: "operator.write" },
{ name: "node.invoke", scope: "operator.write" },

View File

@@ -113,6 +113,8 @@ describe("gateway/node-catalog", () => {
caps: ["camera", "screen"],
declaredCommands: ["screen.snapshot", "system.run"],
commands: ["screen.snapshot", "system.run"],
declaredNodePluginTools: [],
nodePluginTools: [],
remoteIp: "100.0.0.11",
pathEnv: "/usr/bin:/bin",
connectedAtMs,
@@ -222,6 +224,8 @@ describe("gateway/node-catalog", () => {
caps: ["canvas"],
declaredCommands: ["canvas.snapshot"],
commands: ["canvas.snapshot"],
declaredNodePluginTools: [],
nodePluginTools: [],
connectedAtMs: 1,
},
],

View File

@@ -147,6 +147,7 @@ function buildEffectiveKnownNode(entry: {
commands: live
? uniqueSortedStrings(live.commands)
: uniqueSortedStrings(nodePairing?.commands),
nodePluginTools: live?.nodePluginTools,
pathEnv: live?.pathEnv,
permissions: live?.permissions ?? nodePairing?.permissions,
connectedAtMs: live?.connectedAtMs,

View File

@@ -109,6 +109,91 @@ describe("gateway/node-command-policy", () => {
expect(allowlist.has("canvas.present")).toBe(true);
});
it("adds explicitly defaulted plugin node-host agent tools from the active registry", () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands ??= [];
registry.nodeHostCommands.push(
{
pluginId: "remote",
pluginName: "Remote",
source: "/extensions/remote/index.ts",
rootDir: "/extensions/remote",
command: {
command: "remote.echo",
agentTool: {
name: "remote_echo",
description: "Echo from a node host",
defaultPlatforms: ["linux"],
},
handle: async () => "{}",
},
},
{
pluginId: "remote",
pluginName: "Remote",
source: "/extensions/remote/index.ts",
rootDir: "/extensions/remote",
command: {
command: "remote.manual",
agentTool: {
name: "remote_manual",
description: "Manual allowlist node-host tool",
},
handle: async () => "{}",
},
},
{
pluginId: "remote",
pluginName: "Remote",
source: "/extensions/remote/index.ts",
rootDir: "/extensions/remote",
command: {
command: "remote.dangerous",
dangerous: true,
agentTool: {
name: "remote_dangerous",
description: "Dangerous node-host tool",
defaultPlatforms: ["linux"],
},
handle: async () => "{}",
},
},
);
setActivePluginRegistry(registry);
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
platform: "linux",
deviceFamily: "Linux",
});
expect(allowlist.has("remote.echo")).toBe(true);
expect(allowlist.has("remote.manual")).toBe(false);
expect(allowlist.has("remote.dangerous")).toBe(false);
expect(
normalizeDeclaredNodeCommands({
declaredCommands: ["remote.echo", "remote.dangerous"],
allowlist,
}),
).toEqual(["remote.echo"]);
});
it("does not allow connected node plugin tools without a registry default or config allowlist", () => {
const allowlist = resolveNodeCommandAllowlist({} as OpenClawConfig, {
platform: "macos",
deviceFamily: "Mac",
commands: ["remote.echo"],
});
expect(allowlist.has("remote.echo")).toBe(false);
expect(
isNodeCommandAllowed({
command: "remote.echo",
declaredCommands: ["remote.echo"],
allowlist,
}),
).toEqual({ ok: false, reason: "command not allowlisted" });
});
it("does not grant host command defaults for platform prefix aliases", () => {
const cfg = {} as OpenClawConfig;
const cases = [

View File

@@ -241,14 +241,23 @@ function listDefaultPluginNodeCommands(platformId: PlatformId): string[] {
if (!registry) {
return [];
}
const commands = (registry.nodeInvokePolicies ?? []).flatMap((entry) => {
const policyCommands = (registry.nodeInvokePolicies ?? []).flatMap((entry) => {
if (entry.policy.dangerous === true) {
return [];
}
const defaults = entry.policy.defaultPlatforms ?? [];
return defaults.includes(platformId) ? entry.policy.commands : [];
});
return normalizeUniqueStringEntries(commands);
const nodeHostCommands = (registry.nodeHostCommands ?? [])
.filter((entry) => {
if (entry.command.dangerous === true) {
return false;
}
const defaults = entry.command.agentTool?.defaultPlatforms ?? [];
return defaults.includes(platformId);
})
.map((entry) => entry.command.command);
return normalizeUniqueStringEntries([...policyCommands, ...nodeHostCommands]);
}
export function isForegroundRestrictedPluginNodeCommand(command: string): boolean {

View File

@@ -1,13 +1,15 @@
/**
* Node connect reconciliation tests.
*/
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../packages/gateway-protocol/src/client-info.js";
import type { ConnectParams } from "../../packages/gateway-protocol/src/index.js";
import type { NodePairingPairedNode, NodePairingRequestInput } from "../infra/node-pairing.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { reconcileNodePairingOnConnect } from "./node-connect-reconcile.js";
function makeNodeConnectParams(overrides?: Partial<ConnectParams>): ConnectParams {
@@ -65,6 +67,10 @@ function expectNodePairingRequest(
}
describe("reconcileNodePairingOnConnect", () => {
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("includes declared permissions in pending node pairing requests", async () => {
const requestPairing = makePendingPairingRequest("req-1");
@@ -118,6 +124,53 @@ describe("reconcileNodePairingOnConnect", () => {
);
});
it("keeps registry-bound node plugin tool commands in first-time pairing", async () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "remote",
pluginName: "Remote",
source: "/extensions/remote/index.ts",
rootDir: "/extensions/remote",
command: {
command: "remote.echo",
agentTool: {
name: "remote_echo",
description: "Echo from a node host",
defaultPlatforms: ["macos"],
},
handle: async () => "{}",
},
},
];
setActivePluginRegistry(registry);
const requestPairing = makePendingPairingRequest("req-plugin-tool");
const result = await reconcileNodePairingOnConnect({
cfg: {} as never,
connectParams: makeNodeConnectParams({
client: {
id: GATEWAY_CLIENT_IDS.NODE_HOST,
version: "test",
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: ["remote.echo"],
}),
pairedNode: null,
requestPairing,
});
expect(result.declaredCommands).toEqual(["remote.echo"]);
expect(result.effectiveCommands).toEqual([]);
expect(requestPairing).toHaveBeenCalledWith(
expect.objectContaining({
commands: ["remote.echo"],
}),
);
});
it.each([
["conflicts with device family", { deviceFamily: "iPhone" }],
["omits device family", {}],

View File

@@ -34,6 +34,8 @@ function createNodeSession(): NodeSession {
caps: [],
declaredCommands: ["demo.read"],
commands: ["demo.read"],
declaredNodePluginTools: [],
nodePluginTools: [],
connectedAtMs: 0,
};
}

View File

@@ -0,0 +1,163 @@
/** Connected node-hosted plugin tools available to agent tool resolution. */
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
export type ConnectedNodePluginTool = {
nodeId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
descriptor: NodePluginToolDescriptor;
};
export type RegisteredNodePluginToolCommand = {
pluginId: string;
command: {
command?: string;
agentTool?: {
name?: string;
description?: string;
parameters?: unknown;
mcp?: {
server?: string;
tool?: string;
};
};
};
};
const toolsByNodeId = new Map<string, ConnectedNodePluginTool[]>();
const NODE_PLUGIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
let snapshotVersion = 0;
function bumpSnapshotVersion(): void {
snapshotVersion += 1;
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function defaultParameters(): Record<string, unknown> {
return { type: "object", properties: {}, additionalProperties: true };
}
function isProviderSafeToolName(value: string): boolean {
return NODE_PLUGIN_TOOL_NAME_RE.test(value);
}
export function createRegisteredNodePluginToolDescriptorMap(
commands?: readonly RegisteredNodePluginToolCommand[],
): Map<string, NodePluginToolDescriptor> {
const descriptors = new Map<string, NodePluginToolDescriptor>();
for (const entry of commands ?? []) {
const agentTool = entry.command.agentTool;
const name = normalizeString(agentTool?.name);
const description = normalizeString(agentTool?.description);
const command = normalizeString(entry.command.command);
if (!isProviderSafeToolName(name) || !description || !command) {
continue;
}
const mcpServer = normalizeString(agentTool?.mcp?.server);
const mcpTool = normalizeString(agentTool?.mcp?.tool);
descriptors.set(`${entry.pluginId}\0${name}\0${command}`, {
pluginId: entry.pluginId,
name,
description,
parameters: normalizeRecord(agentTool?.parameters) ?? defaultParameters(),
command,
...(mcpServer && mcpTool ? { mcp: { server: mcpServer, tool: mcpTool } } : {}),
});
}
return descriptors;
}
export function normalizeNodePluginToolDescriptors(params: {
tools?: readonly NodePluginToolDescriptor[];
allowedCommands?: readonly string[];
registeredDescriptors: ReadonlyMap<string, NodePluginToolDescriptor>;
}): NodePluginToolDescriptor[] {
const allowedCommands = params.allowedCommands ? new Set(params.allowedCommands) : undefined;
const byKey = new Map<string, NodePluginToolDescriptor>();
for (const tool of params.tools ?? []) {
const pluginId = normalizeString(tool.pluginId);
const name = normalizeString(tool.name);
const command = normalizeString(tool.command);
if (!pluginId || !isProviderSafeToolName(name) || !command) {
continue;
}
const registeredDescriptor = params.registeredDescriptors.get(
`${pluginId}\0${name}\0${command}`,
);
if (!registeredDescriptor) {
continue;
}
if (allowedCommands && !allowedCommands.has(command)) {
continue;
}
byKey.set(`${pluginId}\0${name}`, registeredDescriptor);
}
return [...byKey.values()].toSorted(
(left, right) =>
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
);
}
export function replaceConnectedNodePluginTools(params: {
nodeId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
tools: readonly NodePluginToolDescriptor[];
}): void {
if (params.tools.length === 0) {
const removed = toolsByNodeId.delete(params.nodeId);
if (removed) {
bumpSnapshotVersion();
}
return;
}
toolsByNodeId.set(
params.nodeId,
params.tools.map((descriptor) => ({
nodeId: params.nodeId,
displayName: params.displayName,
platform: params.platform,
remoteIp: params.remoteIp,
descriptor,
})),
);
bumpSnapshotVersion();
}
export function removeConnectedNodePluginTools(nodeId: string): void {
const removed = toolsByNodeId.delete(nodeId);
if (removed) {
bumpSnapshotVersion();
}
}
export function listConnectedNodePluginTools(): ConnectedNodePluginTool[] {
return [...toolsByNodeId.values()]
.flat()
.toSorted(
(left, right) =>
left.descriptor.pluginId.localeCompare(right.descriptor.pluginId) ||
left.descriptor.name.localeCompare(right.descriptor.name) ||
left.nodeId.localeCompare(right.nodeId),
);
}
export function getConnectedNodePluginToolsVersion(): number {
return snapshotVersion;
}
export function resetConnectedNodePluginToolsForTest(): void {
toolsByNodeId.clear();
bumpSnapshotVersion();
}

View File

@@ -6,12 +6,21 @@ import {
MAX_DATE_TIMESTAMP_MS,
MAX_TIMER_TIMEOUT_MS,
} from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { onDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import {
listConnectedNodePluginTools,
resetConnectedNodePluginToolsForTest,
} from "./node-plugin-tool-snapshot.js";
import { NodeRegistry, serializeEventPayload } from "./node-registry.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import type { GatewayWsClient } from "./server/ws-types.js";
let testNodeHostCommands: NonNullable<
ReturnType<typeof createEmptyPluginRegistry>["nodeHostCommands"]
> = [];
function makeClient(
connId: string,
nodeId: string,
@@ -22,6 +31,7 @@ function makeClient(
version?: string;
caps?: string[];
commands?: string[];
nodePluginTools?: GatewayWsClient["connect"]["nodePluginTools"];
permissions?: Record<string, boolean>;
declaredCaps?: string[];
declaredCommands?: string[];
@@ -59,6 +69,7 @@ function makeClient(
},
caps: opts.caps ?? [],
commands: opts.commands ?? [],
nodePluginTools: opts.nodePluginTools,
permissions: opts.permissions,
declaredCaps: opts.declaredCaps,
declaredCommands: opts.declaredCommands,
@@ -67,6 +78,45 @@ function makeClient(
};
}
afterEach(() => {
testNodeHostCommands = [];
resetConnectedNodePluginToolsForTest();
});
function registerDemoNodePluginTool(params: {
name: string;
command: string;
description?: string;
parameters?: Record<string, unknown>;
dangerous?: boolean;
}) {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands ??= [];
registry.nodeHostCommands.push({
pluginId: "demo",
pluginName: "Demo",
source: "test",
rootDir: "test",
command: {
command: params.command,
...(params.dangerous ? { dangerous: true } : {}),
agentTool: {
name: params.name,
description: params.description ?? "Demo node-host tool",
...(params.parameters ? { parameters: params.parameters } : {}),
},
handle: async () => "{}",
},
});
testNodeHostCommands = registry.nodeHostCommands;
}
function createTestNodeRegistry(): NodeRegistry {
return new NodeRegistry({
listRegisteredNodePluginToolCommands: () => testNodeHostCommands,
});
}
function makeConnectivitySocket(emitPong: boolean) {
const socket = new EventEmitter() as EventEmitter & {
readyState: number;
@@ -129,7 +179,7 @@ function authorizeSystemRun(registry: NodeRegistry, overrides: Partial<SystemRun
describe("gateway/node-registry", () => {
it("checks node websocket connectivity with ping/pong", async () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
registry.register(
makeClient("conn-1", "node-1", [], {
socket: makeConnectivitySocket(true),
@@ -141,7 +191,7 @@ describe("gateway/node-registry", () => {
});
it("reports stale node websocket connectivity before invoke timeout", async () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
registry.register(
makeClient("conn-1", "node-1", [], {
socket: makeConnectivitySocket(false),
@@ -158,7 +208,7 @@ describe("gateway/node-registry", () => {
});
it("keeps a reconnected node when the old connection unregisters", async () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const oldFrames: string[] = [];
const newClient = makeClient("conn-new", "node-1");
@@ -186,7 +236,7 @@ describe("gateway/node-registry", () => {
});
it("matches pending system.run events to the issuing connection", async () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerLinuxNode(registry);
const { invoke, request } = invokeSystemRun(registry, frames, {
runId: "run-1",
@@ -241,7 +291,7 @@ describe("gateway/node-registry", () => {
it("keeps no-timeout system.run event authorization after invoke timeout", async () => {
vi.useFakeTimers();
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
try {
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(
@@ -271,7 +321,7 @@ describe("gateway/node-registry", () => {
it("caps oversized invoke and system.run authorization timers", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
try {
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(
@@ -302,7 +352,7 @@ describe("gateway/node-registry", () => {
it("expires system.run authorization when the process clock is invalid", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
runId: "run-invalid-clock",
@@ -326,7 +376,7 @@ describe("gateway/node-registry", () => {
it("expires system.run authorization when the expiry would exceed the Date range", () => {
vi.useFakeTimers();
vi.setSystemTime(MAX_DATE_TIMESTAMP_MS);
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
try {
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
@@ -348,7 +398,7 @@ describe("gateway/node-registry", () => {
});
it("matches a single system.run event when legacy payload omits runId", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
runId: "run-legacy",
@@ -361,7 +411,7 @@ describe("gateway/node-registry", () => {
});
it("rejects runId-less system.run events for non-legacy nodes", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerLinuxNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
runId: "run-required",
@@ -374,7 +424,7 @@ describe("gateway/node-registry", () => {
});
it("generates and forwards a runId when system.run params omit it", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke, request } = invokeSystemRun(registry, frames, {
command: ["/bin/sh", "-lc", "printf ok"],
@@ -393,7 +443,7 @@ describe("gateway/node-registry", () => {
});
it("clears system.run event authorization when invoke result fails", async () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke, request } = invokeSystemRun(registry, frames, {
runId: "run-failed",
@@ -424,7 +474,7 @@ describe("gateway/node-registry", () => {
});
it("matches legacy macOS exec events with runtime-generated runId when single pending run matches", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
runId: "gateway-run",
@@ -441,7 +491,7 @@ describe("gateway/node-registry", () => {
});
it("rejects mismatched runId fallback for non-macOS nodes", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerLinuxNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
runId: "gateway-run",
@@ -458,7 +508,7 @@ describe("gateway/node-registry", () => {
});
it("matches system.run events with emitted session key when invoke omitted sessionKey", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke } = invokeSystemRun(registry, frames, {
runId: "run-without-session",
@@ -474,7 +524,7 @@ describe("gateway/node-registry", () => {
});
it("rejects runId-less system.run events when the connection has multiple matches", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames = registerNode(registry);
const { invoke: first } = invokeSystemRun(registry, frames, {
runId: "run-a",
@@ -492,7 +542,7 @@ describe("gateway/node-registry", () => {
});
it("sends raw event payload JSON without changing the envelope shape", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const frames: string[] = [];
registry.register(makeClient("conn-1", "node-1", frames), {});
const payload = serializeEventPayload({ foo: "bar" });
@@ -537,7 +587,7 @@ describe("gateway/node-registry", () => {
resetDiagnosticEventsForTest();
const diagnosticEvents: unknown[] = [];
const stopDiagnostics = onDiagnosticEvent((event) => diagnosticEvents.push(event));
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const socket = {
bufferedAmount: MAX_BUFFERED_BYTES + 1,
send: vi.fn(),
@@ -574,7 +624,7 @@ describe("gateway/node-registry", () => {
});
it("refreshes effective live surface within the declared surface", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
caps: [],
commands: [],
@@ -600,8 +650,253 @@ describe("gateway/node-registry", () => {
expect((client.connect as { commands?: string[] }).commands).toEqual(["talk.ptt.start"]);
});
it("keeps node-hosted plugin tools inside the approved command surface", () => {
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
commands: ["demo.echo"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Echo through the node",
command: "demo.echo",
},
{
pluginId: "demo",
name: "demo_blocked",
description: "Blocked command",
command: "demo.blocked",
},
],
});
const session = registry.register(client, {});
expect(session.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
"demo_echo",
]);
registry.updateSurface("node-1", {
caps: [],
commands: [],
});
expect(registry.get("node-1")?.nodePluginTools).toEqual([]);
expect(listConnectedNodePluginTools()).toEqual([]);
});
it("keeps dangerous node-hosted plugin tools once explicitly approved", () => {
registerDemoNodePluginTool({
name: "demo_dangerous",
command: "demo.dangerous",
dangerous: true,
});
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
commands: ["demo.dangerous"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo_dangerous",
description: "Dangerous command",
command: "demo.dangerous",
},
],
});
const session = registry.register(client, {});
expect(session.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_dangerous"]);
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
"demo_dangerous",
]);
});
it("drops node-hosted plugin tools with provider-unsafe names", () => {
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
commands: ["demo.echo"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo.echo",
description: "Invalid provider tool name",
command: "demo.echo",
},
{
pluginId: "demo",
name: "demo_echo",
description: "Valid provider tool name",
command: "demo.echo",
},
],
});
const session = registry.register(client, {});
expect(session.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
"demo_echo",
]);
});
it("drops node-hosted plugin tools that do not match a plugin registration", () => {
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
commands: ["system.run"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Spoofed command",
command: "system.run",
},
],
});
const session = registry.register(client, {});
expect(session.nodePluginTools).toEqual([]);
expect(listConnectedNodePluginTools()).toEqual([]);
});
it("uses registry metadata for node-hosted plugin tool descriptors", () => {
registerDemoNodePluginTool({
name: "demo_echo",
command: "demo.echo",
description: "Trusted registry description",
parameters: {
type: "object",
properties: { text: { type: "string" } },
},
});
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
commands: ["demo.echo"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Injected node description",
parameters: {
type: "object",
properties: { secret: { type: "string" } },
},
command: "demo.echo",
},
],
});
const session = registry.register(client, {});
expect(session.nodePluginTools).toEqual([
{
pluginId: "demo",
name: "demo_echo",
description: "Trusted registry description",
parameters: {
type: "object",
properties: { text: { type: "string" } },
},
command: "demo.echo",
},
]);
});
it("keeps declared node-hosted plugin tools for later command approval", () => {
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
commands: [],
declaredCommands: ["demo.echo"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Echo through the node",
command: "demo.echo",
},
],
});
const session = registry.register(client, {});
expect(session.nodePluginTools).toEqual([]);
expect(listConnectedNodePluginTools()).toEqual([]);
registry.updateSurface("node-1", {
caps: [],
commands: ["demo.echo"],
});
expect(registry.get("node-1")?.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
"demo_echo",
]);
});
it("refreshes node-hosted plugin tools after plugin descriptors load", () => {
const registry = createTestNodeRegistry();
registry.register(
makeClient("conn-1", "node-1", [], {
commands: ["demo.echo"],
nodePluginTools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Echo through the node",
command: "demo.echo",
},
],
}),
{},
);
expect(registry.get("node-1")?.nodePluginTools).toEqual([]);
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
registry.refreshNodePluginTools();
expect(registry.get("node-1")?.nodePluginTools.map((tool) => tool.name)).toEqual(["demo_echo"]);
expect(listConnectedNodePluginTools().map((entry) => entry.descriptor.name)).toEqual([
"demo_echo",
]);
});
it("ignores node plugin tool updates from stale connections", () => {
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
const registry = createTestNodeRegistry();
registry.register(
makeClient("conn-old", "node-1", [], {
commands: ["demo.echo"],
}),
{},
);
registry.register(
makeClient("conn-new", "node-1", [], {
commands: ["demo.echo"],
}),
{},
);
const updated = registry.updateNodePluginTools("node-1", "conn-old", [
{
pluginId: "demo",
name: "demo_echo",
description: "Echo through the old node connection",
command: "demo.echo",
},
]);
expect(updated).toBeNull();
expect(registry.get("node-1")?.nodePluginTools).toEqual([]);
expect(listConnectedNodePluginTools()).toEqual([]);
});
it("clears effective permissions when explicitly removed", () => {
const registry = new NodeRegistry();
const registry = createTestNodeRegistry();
const client = makeClient("conn-1", "node-1", [], {
permissions: { camera: false },
declaredPermissions: { camera: false },

View File

@@ -7,7 +7,15 @@ import {
resolveExpiresAtMsFromDurationMs,
resolveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
import { logRejectedLargePayload } from "../logging/diagnostic-payload.js";
import {
createRegisteredNodePluginToolDescriptorMap,
normalizeNodePluginToolDescriptors,
removeConnectedNodePluginTools,
replaceConnectedNodePluginTools,
type RegisteredNodePluginToolCommand,
} from "./node-plugin-tool-snapshot.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -30,6 +38,8 @@ export type NodeSession = {
caps: string[];
declaredCommands: string[];
commands: string[];
declaredNodePluginTools: NodePluginToolDescriptor[];
nodePluginTools: NodePluginToolDescriptor[];
declaredPermissions?: Record<string, boolean>;
permissions?: Record<string, boolean>;
pathEnv?: string;
@@ -96,6 +106,12 @@ export type SerializedEventPayload = {
readonly [SERIALIZED_EVENT_PAYLOAD]: true;
};
export type NodeRegistryOptions = {
listRegisteredNodePluginToolCommands?:
| (() => readonly RegisteredNodePluginToolCommand[] | undefined)
| undefined;
};
/** Serialize an event payload once so fanout can reuse the same JSON string. */
export function serializeEventPayload(payload: unknown): SerializedEventPayload | null {
if (payload === undefined) {
@@ -178,6 +194,42 @@ export class NodeRegistry {
private pendingInvokes = new Map<string, PendingInvoke>();
private authorizedSystemRunEvents = new Map<string, AuthorizedSystemRunEvent>();
constructor(private readonly options: NodeRegistryOptions = {}) {}
private normalizePluginToolDescriptors(params: {
tools?: readonly NodePluginToolDescriptor[];
allowedCommands?: readonly string[];
}): NodePluginToolDescriptor[] {
return normalizeNodePluginToolDescriptors({
...params,
registeredDescriptors: createRegisteredNodePluginToolDescriptorMap(
this.options.listRegisteredNodePluginToolCommands?.(),
),
});
}
private replaceEffectiveNodePluginTools(node: NodeSession): void {
const nodePluginTools = this.normalizePluginToolDescriptors({
tools: node.declaredNodePluginTools,
allowedCommands: node.commands,
});
node.nodePluginTools = nodePluginTools;
node.client.connect.nodePluginTools = nodePluginTools;
replaceConnectedNodePluginTools({
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
remoteIp: node.remoteIp,
tools: nodePluginTools,
});
}
refreshNodePluginTools(): void {
for (const node of this.nodesById.values()) {
this.replaceEffectiveNodePluginTools(node);
}
}
/** Register a websocket client as the current connection for its node id. */
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
const connect = client.connect;
@@ -208,6 +260,13 @@ export class NodeRegistry {
typeof (connect as { pathEnv?: string }).pathEnv === "string"
? (connect as { pathEnv?: string }).pathEnv
: undefined;
const declaredNodePluginTools = Array.isArray(connect.nodePluginTools)
? [...connect.nodePluginTools]
: [];
const nodePluginTools = this.normalizePluginToolDescriptors({
tools: declaredNodePluginTools,
allowedCommands: commands,
});
const session: NodeSession = {
nodeId,
connId: client.connId,
@@ -226,6 +285,8 @@ export class NodeRegistry {
caps,
declaredCommands,
commands,
declaredNodePluginTools,
nodePluginTools,
declaredPermissions,
permissions,
pathEnv,
@@ -233,6 +294,13 @@ export class NodeRegistry {
};
this.nodesById.set(nodeId, session);
this.nodesByConn.set(client.connId, nodeId);
replaceConnectedNodePluginTools({
nodeId,
displayName: session.displayName,
platform: session.platform,
remoteIp: session.remoteIp,
tools: nodePluginTools,
});
return session;
}
@@ -246,6 +314,7 @@ export class NodeRegistry {
const unregistersCurrentNode = this.nodesById.get(nodeId)?.connId === connId;
if (unregistersCurrentNode) {
this.nodesById.delete(nodeId);
removeConnectedNodePluginTools(nodeId);
}
for (const [id, pending] of this.pendingInvokes.entries()) {
if (pending.connId !== connId) {
@@ -366,6 +435,20 @@ export class NodeRegistry {
return this.updateSurface(nodeId, { commands });
}
updateNodePluginTools(
nodeId: string,
connId: string | undefined,
tools: readonly NodePluginToolDescriptor[],
): NodeSession | null {
const node = this.nodesById.get(nodeId);
if (!node || node.connId !== connId) {
return null;
}
node.declaredNodePluginTools = [...tools];
this.replaceEffectiveNodePluginTools(node);
return node;
}
updateSurface(
nodeId: string,
surface: {
@@ -384,6 +467,7 @@ export class NodeRegistry {
const nextCommands = surface.commands.filter((command) => declaredCommands.has(command));
node.commands = nextCommands;
(node.client.connect as { commands?: string[] }).commands = nextCommands;
this.replaceEffectiveNodePluginTools(node);
if ("caps" in surface) {
const declaredCaps = new Set(node.declaredCaps);

View File

@@ -25,10 +25,12 @@ describe("gateway role policy", () => {
test("authorizes roles against node vs operator methods", () => {
expect(isRoleAuthorizedForMethod("node", "node.event")).toBe(true);
expect(isRoleAuthorizedForMethod("node", "node.pluginSurface.refresh")).toBe(true);
expect(isRoleAuthorizedForMethod("node", "node.pluginTools.update")).toBe(true);
expect(isRoleAuthorizedForMethod("node", "node.pending.drain")).toBe(true);
expect(isRoleAuthorizedForMethod("node", "status")).toBe(false);
expect(isRoleAuthorizedForMethod("operator", "status")).toBe(true);
expect(isRoleAuthorizedForMethod("operator", "node.pluginSurface.refresh")).toBe(false);
expect(isRoleAuthorizedForMethod("operator", "node.pluginTools.update")).toBe(false);
expect(isRoleAuthorizedForMethod("operator", "node.pending.drain")).toBe(false);
expect(isRoleAuthorizedForMethod("operator", "node.event")).toBe(false);
});

View File

@@ -17,6 +17,10 @@ describe("listGatewayMethods", () => {
expect(listGatewayMethods()).toContain("node.pluginSurface.refresh");
});
it("advertises node plugin tool catalog updates", () => {
expect(listGatewayMethods()).toContain("node.pluginTools.update");
});
it("advertises ClawHub skill trust methods", () => {
const methods = listGatewayMethods();
expect(methods).toContain("skills.securityVerdicts");

View File

@@ -515,6 +515,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
"node.list",
"node.describe",
"node.pluginSurface.refresh",
"node.pluginTools.update",
"node.pending.pull",
"node.pending.ack",
"node.invoke",

View File

@@ -22,6 +22,7 @@ import {
validateNodePairRemoveParams,
validateNodePairRequestParams,
validateNodePairVerifyParams,
validateNodePluginToolsUpdateParams,
validateNodeRenameParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { getRuntimeConfig } from "../../config/io.js";
@@ -1000,6 +1001,33 @@ export const nodeHandlers: GatewayRequestHandlers = {
respond,
});
},
"node.pluginTools.update": async ({ params, respond, client, context }) => {
if (!validateNodePluginToolsUpdateParams(params)) {
respondInvalidParams({
respond,
method: "node.pluginTools.update",
validator: validateNodePluginToolsUpdateParams,
});
return;
}
const nodeId = normalizeOptionalString(
client?.connect?.device?.id ?? client?.connect?.client?.id,
);
if (!nodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
const updated = context.nodeRegistry.updateNodePluginTools(
nodeId,
client?.connId,
params.tools,
);
if (!updated) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
respond(true, { nodeId, tools: updated.nodePluginTools }, undefined);
},
"node.pending.pull": async ({ params, respond, client, context }) => {
if (!validateNodeListParams(params)) {
respondInvalidParams({

View File

@@ -68,6 +68,13 @@ const runtimeMocks = vi.hoisted(() => ({
vi.mock("./tools-effective.runtime.js", () => runtimeMocks);
const nodePluginToolSnapshotMocks = vi.hoisted(() => ({
version: 1,
getConnectedNodePluginToolsVersion: vi.fn(() => nodePluginToolSnapshotMocks.version),
}));
vi.mock("../node-plugin-tool-snapshot.js", () => nodePluginToolSnapshotMocks);
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
type ToolsEffectivePayload = {
agentId?: string;
@@ -224,6 +231,7 @@ describe("tools.effective handler", () => {
vi.clearAllMocks();
testing.resetToolsEffectiveCacheForTest();
testing.resetToolsEffectiveNowForTest();
nodePluginToolSnapshotMocks.version = 1;
runtimeMocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace-main");
runtimeMocks.resolveAgentDir.mockReturnValue("/tmp/agents/main/agent");
runtimeMocks.getActivePluginChannelRegistryVersion.mockReturnValue(1);
@@ -345,6 +353,19 @@ describe("tools.effective handler", () => {
expectResponsesOk(first.respond, second.respond);
});
it("recomputes fresh base inventory when connected node plugin tools change", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();
nodePluginToolSnapshotMocks.version = 2;
const second = createInvokeParams({ sessionKey: "main:abc" });
await second.invoke();
expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(2);
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledTimes(2);
expectResponsesOk(first.respond, second.respond);
});
it("keeps separate base inventory cache entries for spawned workspaces", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();

View File

@@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { toErrorObject } from "../../infra/errors.js";
import { logDebug, logWarn } from "../../logger.js";
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
import { getConnectedNodePluginToolsVersion } from "../node-plugin-tool-snapshot.js";
import {
applyFinalEffectiveToolPolicy,
buildBundleMcpToolsFromCatalog,
@@ -55,6 +56,7 @@ type TrustedToolsEffectiveContext = {
runtimeConfigCacheKey: string;
pluginRegistryVersion: number;
channelRegistryVersion: number;
nodePluginToolsVersion: number;
modelProvider?: string;
modelId?: string;
messageProvider?: string;
@@ -93,6 +95,7 @@ function buildToolsEffectiveCacheKey(params: {
config: context.runtimeConfigCacheKey,
pluginRegistry: context.pluginRegistryVersion,
channelRegistry: context.channelRegistryVersion,
nodePluginTools: context.nodePluginToolsVersion,
// MCP fingerprint/server names intentionally stay out of this key: the MCP
// layer is applied after the base cache, so warm/stale runtime state alone
// never invalidates base entries.
@@ -493,6 +496,7 @@ function resolveTrustedToolsEffectiveContext(params: {
const runtimeConfigCacheKey = resolveRuntimeConfigCacheKey(loaded.cfg);
const pluginRegistryVersion = getActivePluginRegistryVersion();
const channelRegistryVersion = getActivePluginChannelRegistryVersion();
const nodePluginToolsVersion = getConnectedNodePluginToolsVersion();
return {
cfg: loaded.cfg,
agentId: sessionAgentId,
@@ -502,6 +506,7 @@ function resolveTrustedToolsEffectiveContext(params: {
runtimeConfigCacheKey,
pluginRegistryVersion,
channelRegistryVersion,
nodePluginToolsVersion,
modelProvider: resolvedModel.provider,
modelId: resolvedModel.model,
messageProvider:

View File

@@ -1,6 +1,10 @@
// Gateway node session runtime factory.
// Creates node registry, subscription, and voice-wake fanout state.
import { NodeRegistry, type SerializedEventPayload } from "./node-registry.js";
import {
NodeRegistry,
type NodeRegistryOptions,
type SerializedEventPayload,
} from "./node-registry.js";
import {
createSessionEventSubscriberRegistry,
createSessionMessageSubscriberRegistry,
@@ -13,8 +17,11 @@ import { hasConnectedTalkNode } from "./server-talk-nodes.js";
/** Creates node registry/subscription runtime state for a gateway server. */
export function createGatewayNodeSessionRuntime(params: {
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
listRegisteredNodePluginToolCommands?: NodeRegistryOptions["listRegisteredNodePluginToolCommands"];
}) {
const nodeRegistry = new NodeRegistry();
const nodeRegistry = new NodeRegistry({
listRegisteredNodePluginToolCommands: params.listRegisteredNodePluginToolCommands,
});
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const nodeSubscriptions = createNodeSubscriptionManager();
const sessionEventSubscribers = createSessionEventSubscriberRegistry();

View File

@@ -938,7 +938,10 @@ export async function startGatewayServer(
nodeUnsubscribeAll,
broadcastVoiceWakeChanged,
hasTalkNodeConnected,
} = createGatewayNodeSessionRuntime({ broadcast });
} = createGatewayNodeSessionRuntime({
broadcast,
listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands,
});
applyGatewayLaneConcurrency(cfgAtStart);
runtimeState = createGatewayServerLiveState({
@@ -1226,6 +1229,7 @@ export async function startGatewayServer(
);
pinActivePluginHttpRouteRegistry(pluginRegistry);
pinActivePluginChannelRegistry(pluginRegistry);
nodeRegistry.refreshNodePluginTools();
};
const refreshAttachedGatewayDiscovery = async (nextPluginRegistry: typeof pluginRegistry) => {
if (minimalTestGateway) {

View File

@@ -51,6 +51,72 @@ describe("plugin node-host registry", () => {
expect(listRegisteredNodeHostCapsAndCommands()).toEqual({
caps: ["browser", "photos"],
commands: ["browser.inspect", "browser.proxy", "photos.proxy"],
nodePluginTools: [],
});
});
it("lists plugin-declared agent tool descriptors", () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
agentTool: {
name: "browser_inspect",
description: "Inspect browser state",
parameters: {
type: "object",
properties: { url: { type: "string" } },
},
},
handle: vi.fn(async () => "{}"),
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(listRegisteredNodeHostCapsAndCommands().nodePluginTools).toEqual([
{
pluginId: "browser",
name: "browser_inspect",
description: "Inspect browser state",
parameters: {
type: "object",
properties: { url: { type: "string" } },
},
command: "browser.proxy",
},
]);
});
it("skips agent tool descriptors with provider-unsafe names", () => {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "browser",
pluginName: "Browser",
command: {
command: "browser.proxy",
cap: "browser",
agentTool: {
name: "browser.inspect",
description: "Inspect browser state",
},
handle: vi.fn(async () => "{}"),
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(listRegisteredNodeHostCapsAndCommands()).toEqual({
caps: ["browser"],
commands: ["browser.proxy"],
nodePluginTools: [],
});
});

View File

@@ -1,5 +1,7 @@
/** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
/**
@@ -34,19 +36,71 @@ export async function ensureNodeHostPluginRegistry(params: {
export function listRegisteredNodeHostCapsAndCommands(): {
caps: string[];
commands: string[];
nodePluginTools: NodePluginToolDescriptor[];
} {
const registry = getActivePluginRegistry();
const caps = new Set<string>();
const commands = new Set<string>();
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
for (const entry of registry?.nodeHostCommands ?? []) {
if (entry.command.cap) {
caps.add(entry.command.cap);
}
commands.add(entry.command.command);
const agentTool = buildNodePluginToolDescriptor(entry);
if (agentTool) {
nodePluginTools.set(`${agentTool.pluginId}\0${agentTool.name}`, agentTool);
}
}
return {
caps: [...caps].toSorted((left, right) => left.localeCompare(right)),
commands: [...commands].toSorted((left, right) => left.localeCompare(right)),
nodePluginTools: [...nodePluginTools.values()].toSorted(
(left, right) =>
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
),
};
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function isProviderSafeToolName(value: string): boolean {
return /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
}
function buildNodePluginToolDescriptor(
entry: PluginNodeHostCommandRegistration,
): NodePluginToolDescriptor | null {
const agentTool = entry.command.agentTool;
if (!agentTool) {
return null;
}
const name = normalizeString(agentTool.name);
const description = normalizeString(agentTool.description);
if (!isProviderSafeToolName(name) || !description) {
return null;
}
const mcpServer = normalizeString(agentTool.mcp?.server);
const mcpTool = normalizeString(agentTool.mcp?.tool);
return {
pluginId: entry.pluginId,
name,
description,
parameters: normalizeRecord(agentTool.parameters) ?? {
type: "object",
properties: {},
additionalProperties: true,
},
command: entry.command.command,
...(mcpServer && mcpTool ? { mcp: { server: mcpServer, tool: mcpTool } } : {}),
};
}

View File

@@ -1,5 +1,5 @@
/** Tests node-host runner command parsing, timeout, and plugin dispatch behavior. */
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayClientOptions } from "../gateway/client.js";
import {
resolveNodeHostGatewayDeviceFamily,
@@ -9,6 +9,7 @@ import {
const mocks = vi.hoisted(() => ({
capturedGatewayClientOptions: [] as GatewayClientOptions[],
capturedGatewayClients: [] as Array<{ request: ReturnType<typeof vi.fn> }>,
ensureNodeHostConfig: vi.fn(async () => ({
version: 1,
nodeId: "node-test",
@@ -36,7 +37,12 @@ vi.mock("../gateway/client-start-readiness.js", () => ({
vi.mock("../gateway/client.js", () => ({
GatewayClient: function GatewayClient(opts: GatewayClientOptions) {
const client = {
request: vi.fn(async () => ({})),
};
mocks.capturedGatewayClientOptions.push(opts);
mocks.capturedGatewayClients.push(client);
return client;
},
}));
@@ -70,10 +76,25 @@ vi.mock("./plugin-node-host.js", () => ({
listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({
caps: [],
commands: [],
nodePluginTools: [
{
pluginId: "test-plugin",
name: "remote_echo",
description: "Echo from node host",
command: "test.echo",
parameters: { type: "object", properties: {} },
},
],
})),
}));
describe("runNodeHost", () => {
beforeEach(() => {
mocks.capturedGatewayClientOptions.length = 0;
mocks.capturedGatewayClients.length = 0;
vi.clearAllMocks();
});
it("maps runtime platforms to gateway platform ids", () => {
expect(resolveNodeHostGatewayPlatform("darwin")).toBe("macos");
expect(resolveNodeHostGatewayPlatform("win32")).toBe("windows");
@@ -101,5 +122,36 @@ describe("runNodeHost", () => {
expect(mocks.capturedGatewayClientOptions[0]?.deviceFamily).toBe(
resolveNodeHostGatewayDeviceFamily(process.platform),
);
expect(mocks.capturedGatewayClients[0]?.request).not.toHaveBeenCalled();
});
it("publishes node plugin tools only after gateway hello succeeds", async () => {
await expect(
runNodeHost({
gatewayHost: "127.0.0.1",
gatewayPort: 18789,
}),
).rejects.toThrow("event loop readiness timeout");
const options = mocks.capturedGatewayClientOptions[0];
const client = mocks.capturedGatewayClients[0];
expect(client?.request).not.toHaveBeenCalled();
options?.onHelloOk?.({
protocol: 1,
features: { methods: [], events: [] },
} as unknown as Parameters<NonNullable<GatewayClientOptions["onHelloOk"]>>[0]);
expect(client?.request).toHaveBeenCalledWith("node.pluginTools.update", {
tools: [
{
pluginId: "test-plugin",
name: "remote_echo",
description: "Echo from node host",
command: "test.echo",
parameters: { type: "object", properties: {} },
},
],
});
});
});

View File

@@ -7,7 +7,11 @@ import {
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js";
import { GatewayClient, type GatewayReconnectPausedInfo } from "../gateway/client.js";
import {
GatewayClient,
GatewayClientRequestError,
type GatewayReconnectPausedInfo,
} from "../gateway/client.js";
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
@@ -114,6 +118,28 @@ export function handleNodeHostReconnectPaused(
exit(1);
}
function isUnsupportedNodePluginToolsUpdateError(error: unknown): boolean {
return (
error instanceof GatewayClientRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message.includes("unknown method: node.pluginTools.update")
);
}
async function publishNodePluginTools(client: GatewayClient, tools: unknown[]): Promise<void> {
if (tools.length === 0) {
return;
}
try {
await client.request("node.pluginTools.update", { tools });
} catch (error) {
if (isUnsupportedNodePluginToolsUpdateError(error)) {
return;
}
writeStderrLine(`node host plugin tool publish failed: ${String(error)}`);
}
}
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
if (bin.includes("/") || bin.includes("\\")) {
return null;
@@ -298,6 +324,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
}
void handleInvoke(payload, client, skillBins);
},
onHelloOk: () => {
void publishNodePluginTools(client, pluginNodeHost.nodePluginTools);
},
onConnectError: (err) => {
// keep retrying (handled by GatewayClient)
writeStderrLine(`node host gateway connect failed: ${err.message}`);

View File

@@ -1,3 +1,4 @@
import type { NodePluginToolDescriptor } from "../../../packages/gateway-protocol/src/index.js";
import type { PluginRuntimeChannel } from "./types-channel.js";
import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js";
@@ -63,6 +64,7 @@ export type RuntimeNodeListResult = {
connected?: boolean;
caps?: string[];
commands?: string[];
nodePluginTools?: NodePluginToolDescriptor[];
}>;
};

View File

@@ -2187,6 +2187,20 @@ export type OpenClawPluginNodeHostCommand = {
command: string;
cap?: string;
dangerous?: boolean;
agentTool?: {
name: string;
description: string;
parameters?: Record<string, unknown>;
/**
* Platforms where this node-hosted agent tool should be allowlisted by
* default. Omit to require explicit `gateway.nodes.allowCommands`.
*/
defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">;
mcp?: {
server: string;
tool: string;
};
};
handle: (paramsJSON?: string | null) => Promise<string>;
};

View File

@@ -1,3 +1,5 @@
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
/** Node record returned by gateway node-list endpoints. */
export type NodeListNode = {
nodeId: string;
@@ -14,6 +16,7 @@ export type NodeListNode = {
pathEnv?: string;
caps?: string[];
commands?: string[];
nodePluginTools?: NodePluginToolDescriptor[];
permissions?: Record<string, boolean>;
paired?: boolean;
connected?: boolean;

View File

@@ -6,7 +6,6 @@ import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-syn
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
createManagedTaskFlow as createManagedTaskFlowOrNull,
@@ -69,9 +68,10 @@ async function withFlowRegistryTempDir<T>(run: (root: string) => Promise<T>): Pr
},
async (state) => {
const root = state.stateDir;
process.env.OPENCLAW_STATE_DIR = root;
resetTaskFlowRegistryForTests();
try {
return await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => await run(root));
return await run(root);
} finally {
resetTaskFlowRegistryForTests();
}
@@ -79,6 +79,16 @@ async function withFlowRegistryTempDir<T>(run: (root: string) => Promise<T>): Pr
);
}
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
function restoreOriginalStateDir(): void {
if (ORIGINAL_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
}
}
describe("task-flow-registry store runtime", () => {
beforeEach(() => {
vi.useRealTimers();
@@ -86,6 +96,7 @@ describe("task-flow-registry store runtime", () => {
afterEach(() => {
vi.useRealTimers();
restoreOriginalStateDir();
resetTaskFlowRegistryForTests();
});
@@ -150,7 +161,7 @@ describe("task-flow-registry store runtime", () => {
});
it("rejects corrupt persisted flow rows during sqlite restore", async () => {
await withFlowRegistryTempDir(async (root) => {
await withFlowRegistryTempDir(async () => {
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
@@ -174,7 +185,7 @@ describe("task-flow-registry store runtime", () => {
});
it("drops invalid requester origins during sqlite restore", async () => {
await withFlowRegistryTempDir(async (root) => {
await withFlowRegistryTempDir(async () => {
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
@@ -203,7 +214,7 @@ describe("task-flow-registry store runtime", () => {
});
it("restores persisted wait-state, revision, and cancel intent from sqlite", async () => {
await withFlowRegistryTempDir(async (root) => {
await withFlowRegistryTempDir(async () => {
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
@@ -248,7 +259,7 @@ describe("task-flow-registry store runtime", () => {
});
it("round-trips explicit json null through sqlite", async () => {
await withFlowRegistryTempDir(async (root) => {
await withFlowRegistryTempDir(async () => {
resetTaskFlowRegistryForTests();
const created = createManagedTaskFlow({
@@ -269,7 +280,7 @@ describe("task-flow-registry store runtime", () => {
});
it("prunes large sqlite snapshots without binding every flow id at once", async () => {
await withFlowRegistryTempDir(async (root) => {
await withFlowRegistryTempDir(async () => {
resetTaskFlowRegistryForTests();
const flows = new Map<string, TaskFlowRecord>();
@@ -299,7 +310,7 @@ describe("task-flow-registry store runtime", () => {
if (process.platform === "win32") {
return;
}
await withFlowRegistryTempDir(async (root) => {
await withFlowRegistryTempDir(async () => {
resetTaskFlowRegistryForTests();
createManagedTaskFlow({