Files
openclaw/src/gateway/node-plugin-tool-snapshot.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

141 lines
4.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/index.js";
import {
createRegisteredNodePluginToolDescriptorMap,
normalizeNodePluginToolDescriptors,
} from "./node-plugin-tool-snapshot.js";
function descriptor(name: string, command = "demo.echo"): NodePluginToolDescriptor {
return {
pluginId: "demo",
name,
description: `Description for ${name}`,
command,
};
}
describe("normalizeNodePluginToolDescriptors", () => {
it("trusts unregistered descriptors inside the approved command surface", () => {
const tools = normalizeNodePluginToolDescriptors({
nodeId: "node-1",
tools: [descriptor("demo_echo"), descriptor("demo_blocked", "demo.blocked")],
allowedCommands: ["demo.echo"],
registeredDescriptors: new Map(),
});
expect(tools.map((tool) => tool.descriptor.name)).toEqual(["demo_echo"]);
});
it("drops descriptors that claim the reserved node-mcp id without the MCP shape", () => {
const spoofed: NodePluginToolDescriptor = {
pluginId: "node-mcp",
name: "spoofed_tool",
description: "Claims node-mcp trust for an arbitrary command",
command: "demo.echo",
};
const genuine: NodePluginToolDescriptor = {
pluginId: "node-mcp",
name: "srv_echo",
description: "Real node-hosted MCP tool",
command: "mcp.tools.call.v1",
mcp: { server: "srv", tool: "echo" },
};
const tools = normalizeNodePluginToolDescriptors({
nodeId: "node-1",
tools: [spoofed, genuine],
allowedCommands: ["demo.echo", "mcp.tools.call.v1"],
registeredDescriptors: new Map(),
});
expect(tools.map((tool) => tool.descriptor.name)).toEqual(["srv_echo"]);
});
it("prefers matching registered metadata and caps its description", () => {
const registeredDescriptors = createRegisteredNodePluginToolDescriptorMap([
{
pluginId: "demo",
command: {
command: "demo.echo",
agentTool: {
name: "demo_echo",
description: ` ${"r".repeat(1200)} `,
parameters: { type: "object", properties: { text: { type: "string" } } },
},
},
},
]);
const tools = normalizeNodePluginToolDescriptors({
nodeId: "node-1",
tools: [descriptor("demo_echo")],
allowedCommands: ["demo.echo"],
registeredDescriptors,
});
expect(tools[0]?.descriptor.description).toHaveLength(1024);
expect(tools[0]?.descriptor.parameters).toEqual({
type: "object",
properties: { text: { type: "string" } },
});
});
it("marks descriptors by whether gateway registration backs them", () => {
const registeredDescriptors = createRegisteredNodePluginToolDescriptorMap([
{
pluginId: "demo",
command: {
command: "demo.echo",
agentTool: {
name: "demo_echo",
description: "Registered echo",
},
},
},
]);
const tools = normalizeNodePluginToolDescriptors({
nodeId: "node-1",
tools: [descriptor("demo_echo"), descriptor("demo_status", "demo.status")],
allowedCommands: ["demo.echo", "demo.status"],
registeredDescriptors,
});
expect(
tools.map((tool) => ({ name: tool.descriptor.name, registered: tool.registered })),
).toEqual([
{ name: "demo_echo", registered: true },
{ name: "demo_status", registered: false },
]);
});
it("sorts before keeping at most 128 descriptors", () => {
const tools = Array.from({ length: 130 }, (_, index) =>
descriptor(`tool_${String(index).padStart(3, "0")}`),
).toReversed();
const normalized = normalizeNodePluginToolDescriptors({
nodeId: "node-1",
tools,
allowedCommands: ["demo.echo"],
registeredDescriptors: new Map(),
});
expect(normalized).toHaveLength(128);
expect(normalized[0]?.descriptor.name).toBe("tool_000");
expect(normalized[127]?.descriptor.name).toBe("tool_127");
});
it("returns no descriptors when gateway publication is disabled", () => {
expect(
normalizeNodePluginToolDescriptors({
nodeId: "node-1",
tools: [descriptor("demo_echo")],
allowedCommands: ["demo.echo"],
registeredDescriptors: new Map(),
enabled: false,
}),
).toEqual([]);
});
});