feat: expose node-hosted plugin tools

This commit is contained in:
Peter Steinberger
2026-06-04 19:08:51 +01:00
parent 571179c80b
commit 2af75a93c2
41 changed files with 1461 additions and 19 deletions

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

@@ -3,6 +3,7 @@ import type {
ConnectParams,
EventFrame,
HelloOk,
NodePluginToolDescriptor,
RequestFrame,
ResponseFrame,
} from "@openclaw/gateway-protocol";
@@ -426,6 +427,7 @@ export type GatewayClientOptions = {
scopes?: string[];
caps?: string[];
commands?: string[];
nodePluginTools?: NodePluginToolDescriptor[];
permissions?: Record<string, boolean>;
pathEnv?: string;
env?: NodeJS.ProcessEnv;

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,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

@@ -949,6 +949,7 @@ describe("GatewayClient connect auth payload", () => {
minProtocol?: number;
maxProtocol?: number;
scopes?: string[];
nodePluginTools?: unknown[];
auth?: {
token?: string;
bootstrapToken?: string;
@@ -992,6 +993,26 @@ 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,
nodePluginTools: [
{
pluginId: "demo",
name: "demo_echo",
description: "Echo through the node",
command: "demo.echo",
},
],
});
const { connect } = startClientAndConnect({ client });
expect(connect.params?.nodePluginTools).toBeUndefined();
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,74 @@ 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 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

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

View File

@@ -0,0 +1,128 @@
/** Connected node-hosted plugin tools available to agent tool resolution. */
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js";
export type ConnectedNodePluginTool = {
nodeId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
descriptor: NodePluginToolDescriptor;
};
const toolsByNodeId = new Map<string, ConnectedNodePluginTool[]>();
const NODE_PLUGIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
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);
}
function listRegisteredNodePluginToolDescriptors(): Map<string, NodePluginToolDescriptor> {
const registry = getActiveRuntimePluginRegistry();
const descriptors = new Map<string, NodePluginToolDescriptor>();
for (const entry of registry?.nodeHostCommands ?? []) {
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[];
}): NodePluginToolDescriptor[] {
const allowedCommands = params.allowedCommands ? new Set(params.allowedCommands) : undefined;
const registeredToolDescriptors = listRegisteredNodePluginToolDescriptors();
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 = registeredToolDescriptors.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) {
toolsByNodeId.delete(params.nodeId);
return;
}
toolsByNodeId.set(
params.nodeId,
params.tools.map((descriptor) => ({
nodeId: params.nodeId,
displayName: params.displayName,
platform: params.platform,
remoteIp: params.remoteIp,
descriptor,
})),
);
}
export function removeConnectedNodePluginTools(nodeId: string): void {
toolsByNodeId.delete(nodeId);
}
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 resetConnectedNodePluginToolsForTest(): void {
toolsByNodeId.clear();
}

View File

@@ -6,8 +6,14 @@ 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 { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.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";
@@ -22,6 +28,7 @@ function makeClient(
version?: string;
caps?: string[];
commands?: string[];
nodePluginTools?: GatewayWsClient["connect"]["nodePluginTools"];
permissions?: Record<string, boolean>;
declaredCaps?: string[];
declaredCommands?: string[];
@@ -59,6 +66,7 @@ function makeClient(
},
caps: opts.caps ?? [],
commands: opts.commands ?? [],
nodePluginTools: opts.nodePluginTools,
permissions: opts.permissions,
declaredCaps: opts.declaredCaps,
declaredCommands: opts.declaredCommands,
@@ -67,6 +75,37 @@ function makeClient(
};
}
afterEach(() => {
resetConnectedNodePluginToolsForTest();
resetPluginRuntimeStateForTest();
});
function registerDemoNodePluginTool(params: {
name: string;
command: string;
description?: string;
parameters?: Record<string, unknown>;
}) {
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands ??= [];
registry.nodeHostCommands.push({
pluginId: "demo",
pluginName: "Demo",
source: "test",
rootDir: "test",
command: {
command: params.command,
agentTool: {
name: params.name,
description: params.description ?? "Demo node-host tool",
...(params.parameters ? { parameters: params.parameters } : {}),
},
handle: async () => "{}",
},
});
setActivePluginRegistry(registry);
}
function makeConnectivitySocket(emitPong: boolean) {
const socket = new EventEmitter() as EventEmitter & {
readyState: number;
@@ -600,6 +639,196 @@ 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 = new NodeRegistry();
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("drops node-hosted plugin tools with provider-unsafe names", () => {
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
const registry = new NodeRegistry();
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 = new NodeRegistry();
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 = new NodeRegistry();
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 = new NodeRegistry();
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("ignores node plugin tool updates from stale connections", () => {
registerDemoNodePluginTool({ name: "demo_echo", command: "demo.echo" });
const registry = new NodeRegistry();
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 client = makeClient("conn-1", "node-1", [], {

View File

@@ -7,7 +7,13 @@ 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 {
normalizeNodePluginToolDescriptors,
removeConnectedNodePluginTools,
replaceConnectedNodePluginTools,
} from "./node-plugin-tool-snapshot.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -30,6 +36,8 @@ export type NodeSession = {
caps: string[];
declaredCommands: string[];
commands: string[];
declaredNodePluginTools: NodePluginToolDescriptor[];
nodePluginTools: NodePluginToolDescriptor[];
declaredPermissions?: Record<string, boolean>;
permissions?: Record<string, boolean>;
pathEnv?: string;
@@ -208,6 +216,13 @@ export class NodeRegistry {
typeof (connect as { pathEnv?: string }).pathEnv === "string"
? (connect as { pathEnv?: string }).pathEnv
: undefined;
const declaredNodePluginTools = normalizeNodePluginToolDescriptors({
tools: Array.isArray(connect.nodePluginTools) ? connect.nodePluginTools : [],
});
const nodePluginTools = normalizeNodePluginToolDescriptors({
tools: declaredNodePluginTools,
allowedCommands: commands,
});
const session: NodeSession = {
nodeId,
connId: client.connId,
@@ -226,6 +241,8 @@ export class NodeRegistry {
caps,
declaredCommands,
commands,
declaredNodePluginTools,
nodePluginTools,
declaredPermissions,
permissions,
pathEnv,
@@ -233,6 +250,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 +270,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 +391,35 @@ 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;
}
const declaredNodePluginTools = normalizeNodePluginToolDescriptors({
tools,
});
const nodePluginTools = normalizeNodePluginToolDescriptors({
tools: declaredNodePluginTools,
allowedCommands: node.commands,
});
node.declaredNodePluginTools = declaredNodePluginTools;
node.nodePluginTools = nodePluginTools;
node.client.connect.nodePluginTools = nodePluginTools;
replaceConnectedNodePluginTools({
nodeId,
displayName: node.displayName,
platform: node.platform,
remoteIp: node.remoteIp,
tools: nodePluginTools,
});
return node;
}
updateSurface(
nodeId: string,
surface: {
@@ -384,6 +438,18 @@ export class NodeRegistry {
const nextCommands = surface.commands.filter((command) => declaredCommands.has(command));
node.commands = nextCommands;
(node.client.connect as { commands?: string[] }).commands = nextCommands;
node.nodePluginTools = normalizeNodePluginToolDescriptors({
tools: node.declaredNodePluginTools,
allowedCommands: nextCommands,
});
node.client.connect.nodePluginTools = node.nodePluginTools;
replaceConnectedNodePluginTools({
nodeId,
displayName: node.displayName,
platform: node.platform,
remoteIp: node.remoteIp,
tools: node.nodePluginTools,
});
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

@@ -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;