mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 17:36:05 +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.
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
/** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */
|
|
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js";
|
|
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
|
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "../plugins/types.js";
|
|
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
|
|
|
/**
|
|
* Plugin node-host command registry bridge.
|
|
*
|
|
* Node hosts load the active plugin registry, expose registered capabilities
|
|
* and commands, and dispatch incoming node-host commands by exact command id.
|
|
*/
|
|
|
|
const loadPluginRegistryLoaderModule = createLazyRuntimeModule(
|
|
() => import("../plugins/runtime/runtime-registry-loader.js"),
|
|
);
|
|
|
|
/** Ensure plugin registry data is loaded before node-host command dispatch. */
|
|
export async function ensureNodeHostPluginRegistry(params: {
|
|
config: OpenClawConfig;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): Promise<void> {
|
|
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
|
|
scope: "all",
|
|
config: params.config,
|
|
activationSourceConfig: params.config,
|
|
env: params.env,
|
|
});
|
|
}
|
|
|
|
/** List registered node-host capabilities and command ids in deterministic order. */
|
|
export function listRegisteredNodeHostCapsAndCommands(
|
|
context: OpenClawPluginNodeHostCommandAvailabilityContext,
|
|
): {
|
|
caps: string[];
|
|
commands: string[];
|
|
nodePluginTools: NodePluginToolDescriptor[];
|
|
} {
|
|
const registry = getActivePluginRegistry();
|
|
const caps = new Set<string>();
|
|
const commands = new Set<string>();
|
|
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
|
|
for (const entry of registry?.nodeHostCommands ?? []) {
|
|
// Availability belongs to the node-local plugin. Gateway policy still keeps
|
|
// the command registered so a differently configured remote node can expose it.
|
|
if (entry.command.isAvailable?.(context) === false) {
|
|
continue;
|
|
}
|
|
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<string, unknown> | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: 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 } } : {}),
|
|
};
|
|
}
|
|
|
|
/** Invoke a registered node-host plugin command, or return null for unknown commands. */
|
|
export async function invokeRegisteredNodeHostCommand(
|
|
command: string,
|
|
paramsJSON?: string | null,
|
|
): Promise<string | null> {
|
|
const registry = getActivePluginRegistry();
|
|
const match = (registry?.nodeHostCommands ?? []).find(
|
|
(entry) => entry.command.command === command,
|
|
);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return await match.command.handle(paramsJSON);
|
|
}
|