mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 12:21:45 +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.
188 lines
5.2 KiB
TypeScript
188 lines
5.2 KiB
TypeScript
/** Tests plugin node-host command registry loading, listing, and invocation. */
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
|
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import {
|
|
invokeRegisteredNodeHostCommand,
|
|
listRegisteredNodeHostCapsAndCommands,
|
|
} from "./plugin-node-host.js";
|
|
|
|
const availabilityContext = { config: {}, env: {} };
|
|
|
|
afterEach(() => {
|
|
resetPluginRuntimeStateForTest();
|
|
});
|
|
|
|
describe("plugin node-host registry", () => {
|
|
it("lists plugin-declared caps and commands", () => {
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.nodeHostCommands = [
|
|
{
|
|
pluginId: "browser",
|
|
pluginName: "Browser",
|
|
command: {
|
|
command: "browser.proxy",
|
|
cap: "browser",
|
|
handle: vi.fn(async () => "{}"),
|
|
},
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "photos",
|
|
pluginName: "Photos",
|
|
command: {
|
|
command: "photos.proxy",
|
|
cap: "photos",
|
|
handle: vi.fn(async () => "{}"),
|
|
},
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "browser-dup",
|
|
pluginName: "Browser Dup",
|
|
command: {
|
|
command: "browser.inspect",
|
|
cap: "browser",
|
|
handle: vi.fn(async () => "{}"),
|
|
},
|
|
source: "test",
|
|
},
|
|
];
|
|
setActivePluginRegistry(registry);
|
|
|
|
expect(listRegisteredNodeHostCapsAndCommands(availabilityContext)).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(availabilityContext).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(availabilityContext)).toEqual({
|
|
caps: ["browser"],
|
|
commands: ["browser.proxy"],
|
|
nodePluginTools: [],
|
|
});
|
|
});
|
|
|
|
it("omits commands and capabilities unavailable in the node-local config", () => {
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.nodeHostCommands = [
|
|
{
|
|
pluginId: "browser",
|
|
pluginName: "Browser",
|
|
command: {
|
|
command: "browser.proxy",
|
|
cap: "browser",
|
|
isAvailable: ({ config }) => config.browser?.enabled !== false,
|
|
handle: vi.fn(async () => "{}"),
|
|
},
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "photos",
|
|
pluginName: "Photos",
|
|
command: {
|
|
command: "photos.proxy",
|
|
cap: "photos",
|
|
handle: vi.fn(async () => "{}"),
|
|
},
|
|
source: "test",
|
|
},
|
|
];
|
|
setActivePluginRegistry(registry);
|
|
|
|
expect(
|
|
listRegisteredNodeHostCapsAndCommands({
|
|
config: { browser: { enabled: false } },
|
|
env: {},
|
|
}),
|
|
).toEqual({
|
|
caps: ["photos"],
|
|
commands: ["photos.proxy"],
|
|
nodePluginTools: [],
|
|
});
|
|
});
|
|
|
|
it("dispatches plugin-declared node-host commands", async () => {
|
|
const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? "");
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.nodeHostCommands = [
|
|
{
|
|
pluginId: "browser",
|
|
pluginName: "Browser",
|
|
command: {
|
|
command: "browser.proxy",
|
|
cap: "browser",
|
|
handle,
|
|
},
|
|
source: "test",
|
|
},
|
|
];
|
|
setActivePluginRegistry(registry);
|
|
|
|
await expect(invokeRegisteredNodeHostCommand("browser.proxy", '{"ok":true}')).resolves.toBe(
|
|
'{"ok":true}',
|
|
);
|
|
await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull();
|
|
expect(handle).toHaveBeenCalledWith('{"ok":true}');
|
|
});
|
|
});
|