From 2af75a93c27dfd92cf7d132cb9cd19b59718d104 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 4 Jun 2026 19:08:51 +0100 Subject: [PATCH] feat: expose node-hosted plugin tools --- docs/cli/node.md | 7 + docs/gateway/protocol.md | 8 + docs/plugins/sdk-overview.md | 22 +- docs/plugins/sdk-runtime.md | 8 +- packages/gateway-client/src/client.ts | 2 + packages/gateway-protocol/src/index.test.ts | 29 +++ packages/gateway-protocol/src/index.ts | 11 + .../gateway-protocol/src/schema/frames.ts | 2 + packages/gateway-protocol/src/schema/nodes.ts | 35 +++ .../src/schema/protocol-schemas.ts | 4 + packages/gateway-protocol/src/schema/types.ts | 2 + src/agents/node-plugin-tools.test.ts | 223 +++++++++++++++++ src/agents/node-plugin-tools.ts | 214 ++++++++++++++++ src/agents/openclaw-plugin-tools.ts | 14 +- src/agents/tool-search.test.ts | 40 +++ src/agents/tool-search.ts | 11 +- src/agents/tools-effective-inventory-build.ts | 2 +- src/agents/tools-effective-inventory.test.ts | 44 +++- src/gateway/client.test.ts | 21 ++ src/gateway/client.ts | 7 +- src/gateway/method-scopes.test.ts | 1 + src/gateway/methods/core-descriptors.ts | 1 + src/gateway/node-catalog.test.ts | 4 + src/gateway/node-catalog.ts | 1 + src/gateway/node-command-policy.test.ts | 68 ++++++ src/gateway/node-command-policy.ts | 13 +- src/gateway/node-invoke-plugin-policy.test.ts | 2 + src/gateway/node-plugin-tool-snapshot.ts | 128 ++++++++++ src/gateway/node-registry.test.ts | 231 +++++++++++++++++- src/gateway/node-registry.ts | 66 +++++ src/gateway/role-policy.test.ts | 2 + src/gateway/server-methods-list.test.ts | 4 + src/gateway/server-methods.ts | 1 + src/gateway/server-methods/nodes.ts | 28 +++ src/node-host/plugin-node-host.test.ts | 66 +++++ src/node-host/plugin-node-host.ts | 54 ++++ src/node-host/runner.test.ts | 54 +++- src/node-host/runner.ts | 31 ++- src/plugins/runtime/types.ts | 2 + src/plugins/types.ts | 14 ++ src/shared/node-list-types.ts | 3 + 41 files changed, 1461 insertions(+), 19 deletions(-) create mode 100644 src/agents/node-plugin-tools.test.ts create mode 100644 src/agents/node-plugin-tools.ts create mode 100644 src/gateway/node-plugin-tool-snapshot.ts diff --git a/docs/cli/node.md b/docs/cli/node.md index 1f846d276b23..b7dc84f07ab6 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -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 diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index d24415f18993..fa1c1951b482 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 3131c33d584f..bed58ac24bb9 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -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 | diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index cf430c759e19..6ba44063fca0 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -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. diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 4d81d8a47504..47e7a03fafc9 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -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; pathEnv?: string; env?: NodeJS.ProcessEnv; diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index fe7ceb0f4c67..402ae455d6d2 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -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({ diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 998fdedce958..338278f5ba60 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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( ); export const validateNodeRenameParams = lazyCompile(NodeRenameParamsSchema); export const validateNodeListParams = lazyCompile(NodeListParamsSchema); +export const validateNodePluginToolsUpdateParams = lazyCompile( + NodePluginToolsUpdateParamsSchema, +); export const validateEnvironmentsListParams = lazyCompile( 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, diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 5c5e2b8d3e66..086e3df85330 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -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), diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index 053f85798b9f..330d12b33300 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -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( { diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index b2771eb980fa..e5af12d02077 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index 171ebe6f1862..ce7afb709c07 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -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">; diff --git a/src/agents/node-plugin-tools.test.ts b/src/agents/node-plugin-tools.test.ts new file mode 100644 index 000000000000..077f8d1878f9 --- /dev/null +++ b/src/agents/node-plugin-tools.test.ts @@ -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([]); + }); +}); diff --git a/src/agents/node-plugin-tools.ts b/src/agents/node-plugin-tools.ts new file mode 100644 index 000000000000..719c2cf48e40 --- /dev/null +++ b/src/agents/node-plugin-tools.ts @@ -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[number] & { + command: string; + normalizedName: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isAgentToolResult(value: unknown): value is AgentToolResult { + 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 { + return new Set((values ?? []).map((value) => normalizeToolName(value)).filter(Boolean)); +} + +function toolPolicyAllows(params: { + pluginId: string; + toolName: string; + exposedToolName?: string; + allowlist: Set; + denylist: ReturnType; +}): 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 | 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; + 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(); + 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()), + toolName: descriptor.mcp.tool, + operation: "tool", + }, + } + : {}), + }); + tools.push(tool); + } + return tools; +} diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 10a7bd3796e6..a874dce84c1d 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -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(), + 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, diff --git a/src/agents/tool-search.test.ts b/src/agents/tool-search.test.ts index bd7a892850c8..c0723106bba4 100644 --- a/src/agents/tool-search.test.ts +++ b/src/agents/tool-search.test.ts @@ -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 { 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"); diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index b72c68c44857..e421fcfcedd0 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -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 }; } diff --git a/src/agents/tools-effective-inventory-build.ts b/src/agents/tools-effective-inventory-build.ts index 87d8c854f99c..078d8074d6d7 100644 --- a/src/agents/tools-effective-inventory-build.ts +++ b/src/agents/tools-effective-inventory-build.ts @@ -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 }; diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index f86497ebea7e..e7f1e2ebc7ed 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -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, + pluginMeta: {} as TestPluginMeta, channelMeta: {} as Record, 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; + pluginMeta?: TestPluginMeta; channelMeta?: Record; 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({ diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 8a218d50cb4d..b7dbe4ea6090 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -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({ diff --git a/src/gateway/client.ts b/src/gateway/client.ts index ac3facff9b1d..04b0b443a695 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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; pathEnv?: string; env?: NodeJS.ProcessEnv; diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 93cf2c54be52..ba2af9fdc6ba 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index ad372aac3081..b4df3b1dd160 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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" }, diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 4ce2b2a745f2..73a5ad68c163 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -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, }, ], diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 0ba0bbb721fe..027fda13ccc0 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -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, diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 33d238896b34..75dbd18da305 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -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 = [ diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 0e6ea9caa56b..1e586ceb273e 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -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 { diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index e622641104b8..fc3112070295 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -34,6 +34,8 @@ function createNodeSession(): NodeSession { caps: [], declaredCommands: ["demo.read"], commands: ["demo.read"], + declaredNodePluginTools: [], + nodePluginTools: [], connectedAtMs: 0, }; } diff --git a/src/gateway/node-plugin-tool-snapshot.ts b/src/gateway/node-plugin-tool-snapshot.ts new file mode 100644 index 000000000000..a2053b69d788 --- /dev/null +++ b/src/gateway/node-plugin-tool-snapshot.ts @@ -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(); +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 | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function defaultParameters(): Record { + return { type: "object", properties: {}, additionalProperties: true }; +} + +function isProviderSafeToolName(value: string): boolean { + return NODE_PLUGIN_TOOL_NAME_RE.test(value); +} + +function listRegisteredNodePluginToolDescriptors(): Map { + const registry = getActiveRuntimePluginRegistry(); + const descriptors = new Map(); + 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(); + 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(); +} diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index e58bf96e3a8c..62fc51bf00ff 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -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; 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; +}) { + 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", [], { diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index c427d874c483..32cc06e5a9ac 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -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; permissions?: Record; 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); diff --git a/src/gateway/role-policy.test.ts b/src/gateway/role-policy.test.ts index 35d8a38d3fc5..364c554ccda1 100644 --- a/src/gateway/role-policy.test.ts +++ b/src/gateway/role-policy.test.ts @@ -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); }); diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 3c72f72914a5..6d52e7c0aba8 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -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"); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 0f6ec2b30a29..4eccee62b783 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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", diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index a5622dff03c2..9ac905dfee95 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -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({ diff --git a/src/node-host/plugin-node-host.test.ts b/src/node-host/plugin-node-host.test.ts index 2fc1efde3cc8..e87817e05d14 100644 --- a/src/node-host/plugin-node-host.test.ts +++ b/src/node-host/plugin-node-host.test.ts @@ -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: [], }); }); diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index e2c2b3e5b412..4ce11fdb2bf0 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -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(); const commands = new Set(); + const nodePluginTools = new Map(); 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 | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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 } } : {}), }; } diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index 57cb5471e8de..bdaefd2835c1 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -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 }>, 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>[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: {} }, + }, + ], + }); }); }); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 6ca58a9eb4c5..5efd09231842 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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 { + 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 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}`); diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 8c94d5200e97..7098161f2d6f 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -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[]; }>; }; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 108ddfede8f0..78f2168cbe46 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2187,6 +2187,20 @@ export type OpenClawPluginNodeHostCommand = { command: string; cap?: string; dangerous?: boolean; + agentTool?: { + name: string; + description: string; + parameters?: Record; + /** + * 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; }; diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index d77add44f01f..34283b2c3e8f 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -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; paired?: boolean; connected?: boolean;