Files
openclaw/src/agents/node-plugin-tools.test.ts
Peter Steinberger 49b5b862ac feat: node-hosted plugins — dynamic tools, MCP servers, and skills (#90431)
* feat: node-hosted plugins — dynamic tools, MCP servers, and skills

Nodes become declarative plugin hosts:
- node.pluginTools.update: node hosts publish plugin-registered agent tool
  descriptors; gateway materializes them as agent tools executing via
  node.invoke under the node command allowlist, with tools.effective
  invalidation and node online/offline removal.
- Trusted paired-node descriptors: no gateway-side plugin registration
  required; gateway.nodes.pluginTools.enabled off-switch (default on);
  description/count caps; deterministic node-prefixed collision names.
- Declarative node-hosted MCP: nodeHost.mcp.servers (McpServerConfig shape)
  starts MCP clients on the node host, publishes tools as pluginId node-mcp,
  executes via built-in mcp.tools.call.v1 with per-layer timeouts, failure
  isolation, and orphan-safe shutdown. No re-pairing when servers change.
- Node-hosted skills: node.skills.update publishes ~/.openclaw/skills
  content (64 skills/64KB/512KB caps both sides); gateway merges them into
  the skills snapshot while connected and exec host=node is available, with
  node:// locators, node-prefixed collisions, disabled command dispatch,
  and gateway.nodes.skills.enabled + nodeHost.skills.enabled switches.
- Security: node-supplied pluginIds cannot satisfy pluginId-scoped tool
  allowlists unless gateway-registered; reserved node-mcp id requires the
  core MCP descriptor shape; protocol registry kept out of public
  plugin-sdk dts.
- E2E: pond harness proves publication, MCP round-trip, skills locator, and
  disconnect/reconnect for all three surfaces.

* style: format node-plugin-tools test

* fix(skills): keep status loader unfiltered when eligibility is passed

skills.status started passing eligibility for the node-skill merge, which
flipped loadWorkspaceSkillEntries into filtered mode and dropped disabled
skills from status reports (QA plugin-lifecycle-hot-reload timeout). Status
now merges node skills explicitly around an unfiltered load. Also: regen
docs_map for new node docs sections; add the intentional node-host MCP
onclose suppression to the lint-suppression allowlist.
2026-07-11 10:16:34 -07:00

376 lines
11 KiB
TypeScript

/** Tests connected node-hosted plugin tool materialization. */
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
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(),
}));
function replaceNodePluginTools(
params: Omit<Parameters<typeof replaceConnectedNodePluginTools>[0], "tools"> & {
tools: NodePluginToolDescriptor[];
registered?: boolean;
},
): void {
const { registered = false, tools, ...node } = params;
replaceConnectedNodePluginTools({
...node,
tools: tools.map((descriptor) => ({ descriptor, registered })),
});
}
afterEach(() => {
resetConnectedNodePluginToolsForTest();
vi.mocked(callGatewayTool).mockReset();
});
describe("createNodePluginTools", () => {
it("materializes connected node plugin tools and invokes their node command", async () => {
replaceNodePluginTools({
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("wraps node-host MCP arguments and maps MCP content", async () => {
replaceNodePluginTools({
nodeId: "node-1",
tools: [
{
pluginId: "node-mcp",
name: "docs_search",
description: "Search node-local docs",
command: "mcp.tools.call.v1",
mcp: { server: "docs", tool: "search" },
},
],
});
vi.mocked(callGatewayTool).mockResolvedValueOnce({
payload: {
content: [
{ type: "image", data: "aW1hZ2UtMQ==", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
{ type: "image", data: "aW1hZ2UtMg==", mimeType: "image/png" },
],
structuredContent: { hits: 2 },
},
});
const tool = createNodePluginTools({})[0];
const result = await tool.execute("call-mcp", { query: "needle" });
expect(callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 125_000 },
{
nodeId: "node-1",
command: "mcp.tools.call.v1",
params: { server: "docs", tool: "search", arguments: { query: "needle" } },
timeoutMs: 120_000,
idempotencyKey: "call-mcp",
},
{ scopes: ["operator.write"] },
);
expect(tool.executionMode).toBe("sequential");
expect(result.content).toEqual([
{ type: "image", data: "aW1hZ2UtMQ==", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
{ type: "image", data: "aW1hZ2UtMg==", mimeType: "image/png" },
{ type: "text", text: '{\n "hits": 2\n}' },
]);
});
it("disambiguates node tools that collide with existing tool names", () => {
replaceNodePluginTools({
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(["node_1_remote_echo"]);
});
it("disambiguates matching tool names from different nodes", async () => {
replaceNodePluginTools({
nodeId: "node-a",
displayName: "Node A",
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
replaceNodePluginTools({
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(["node_a_remote_echo", "node_b_remote_echo"]);
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"]) {
replaceNodePluginTools({
nodeId,
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
}
expect(
createNodePluginTools({
toolAllowlist: ["node_b_remote_echo"],
}).map((tool) => tool.name),
).toEqual(["node_b_remote_echo"]);
expect(
createNodePluginTools({
toolDenylist: ["node_b_remote_echo"],
}).map((tool) => tool.name),
).toEqual(["node_a_remote_echo"]);
});
it("keeps numeric node fragments provider-safe", () => {
replaceNodePluginTools({
nodeId: "123",
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(["node_123_remote_echo"]);
});
it("keeps numeric disambiguation when node fragments collide", () => {
for (const nodeId of ["node-a", "node_a"]) {
replaceNodePluginTools({
nodeId,
tools: [
{
pluginId: "remote-demo",
name: "remote_echo",
description: "Echo through a remote node",
command: "remote.echo",
},
],
});
}
expect(createNodePluginTools({}).map((tool) => tool.name)).toEqual([
"node_a_remote_echo",
"node_a_remote_echo_2",
]);
});
it("keeps disambiguated node tool names provider-safe", () => {
const longName = `a${"b".repeat(63)}`;
for (const nodeId of ["node-a", "node-b"]) {
replaceNodePluginTools({
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", () => {
replaceNodePluginTools({
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",
},
],
registered: true,
});
expect(
createNodePluginTools({
toolAllowlist: ["remote-demo"],
toolDenylist: ["remote_status"],
}).map((tool) => tool.name),
).toEqual(["remote_echo"]);
expect(createNodePluginTools({ toolAllowlist: ["other-plugin"] })).toEqual([]);
});
it("trusts plugin-id allowlist entries only for registered tools and node-mcp", () => {
const githubDescriptor: NodePluginToolDescriptor = {
pluginId: "github",
name: "remote_repo_search",
description: "Search repositories through a remote node",
command: "remote.search",
};
replaceNodePluginTools({
nodeId: "node-1",
tools: [githubDescriptor],
});
expect(createNodePluginTools({ toolAllowlist: ["github"] })).toEqual([]);
replaceNodePluginTools({
nodeId: "node-1",
tools: [githubDescriptor],
registered: true,
});
expect(createNodePluginTools({ toolAllowlist: ["github"] }).map((tool) => tool.name)).toEqual([
"remote_repo_search",
]);
replaceNodePluginTools({
nodeId: "node-1",
tools: [{ ...githubDescriptor, pluginId: "node-mcp" }],
});
expect(createNodePluginTools({ toolAllowlist: ["node-mcp"] }).map((tool) => tool.name)).toEqual(
["remote_repo_search"],
);
replaceNodePluginTools({
nodeId: "node-1",
tools: [githubDescriptor],
});
expect(
createNodePluginTools({ toolAllowlist: ["remote_repo_search"] }).map((tool) => tool.name),
).toEqual(["remote_repo_search"]);
expect(
createNodePluginTools({
toolAllowlist: ["remote_repo_search"],
toolDenylist: ["github"],
}),
).toEqual([]);
});
});