mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 14:11:42 +00:00
* 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.
141 lines
4.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|