Files
openclaw/src/gateway/node-skill-descriptors.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

55 lines
1.7 KiB
TypeScript

import type { NodeSkillDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
NODE_SKILL_MAX_CONTENT_BYTES,
NODE_SKILL_MAX_COUNT,
NODE_SKILL_MAX_DESCRIPTION_LENGTH,
NODE_SKILL_MAX_TOTAL_BYTES,
NODE_SKILL_NAME_RE,
} from "../shared/node-skill-constraints.js";
const log = createSubsystemLogger("gateway/node-skills");
export function normalizeNodeSkillDescriptors(params: {
nodeId: string;
skills?: readonly NodeSkillDescriptor[];
enabled?: boolean;
}): NodeSkillDescriptor[] {
if (params.enabled === false) {
return [];
}
const normalized: NodeSkillDescriptor[] = [];
const seen = new Set<string>();
let totalBytes = 0;
let droppedCount = 0;
for (const skill of params.skills ?? []) {
const name = skill.name.trim();
const description = skill.description.trim();
const contentBytes = Buffer.byteLength(skill.content, "utf8");
if (
!NODE_SKILL_NAME_RE.test(name) ||
!description ||
description.length > NODE_SKILL_MAX_DESCRIPTION_LENGTH ||
!skill.content ||
contentBytes > NODE_SKILL_MAX_CONTENT_BYTES ||
seen.has(name) ||
normalized.length >= NODE_SKILL_MAX_COUNT ||
totalBytes + contentBytes > NODE_SKILL_MAX_TOTAL_BYTES
) {
droppedCount += 1;
continue;
}
seen.add(name);
totalBytes += contentBytes;
normalized.push({ name, description, content: skill.content });
}
if (droppedCount > 0) {
log.warn(
`node ${params.nodeId} published ${params.skills?.length ?? 0} skill descriptors; dropped ${droppedCount} invalid or over-limit descriptors`,
);
}
return normalized.toSorted((left, right) => left.name.localeCompare(right.name, "en"));
}