diff --git a/src/plugins/contracts/tool-metadata-registration.contract.test.ts b/src/plugins/contracts/tool-metadata-registration.contract.test.ts new file mode 100644 index 000000000000..b23e35aa73d6 --- /dev/null +++ b/src/plugins/contracts/tool-metadata-registration.contract.test.ts @@ -0,0 +1,104 @@ +// Tool metadata registration tests cover plugin-owned metadata snapshotting. +import { + createPluginRegistryFixture, + registerTestPlugin, +} from "openclaw/plugin-sdk/plugin-test-contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildEffectiveToolInventoryEntries } from "../../agents/tools-effective-inventory-build.js"; +import type { AnyAgentTool } from "../../agents/tools/common.js"; +import type { PluginToolMetadataRegistration } from "../host-hooks.js"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import { setActivePluginRegistry } from "../runtime.js"; +import { createPluginRecord } from "../status.test-helpers.js"; +import { setPluginToolMeta } from "../tools.js"; + +describe("plugin tool metadata registration", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("snapshots metadata before effective tool inventory projection", () => { + let toolNameReads = 0; + let displayNameReads = 0; + let descriptionReads = 0; + let riskReads = 0; + let tagsReads = 0; + const { config, registry } = createPluginRegistryFixture(); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "volatile-metadata", + name: "Volatile Metadata", + contracts: { tools: ["volatile_tool"] }, + }), + register(api) { + api.registerToolMetadata({ + get toolName() { + toolNameReads += 1; + if (toolNameReads > 1) { + throw new Error("toolName getter re-read"); + } + return "volatile_tool"; + }, + get displayName() { + displayNameReads += 1; + if (displayNameReads > 1) { + throw new Error("displayName getter re-read"); + } + return "Volatile Tool"; + }, + get description() { + descriptionReads += 1; + if (descriptionReads > 1) { + throw new Error("description getter re-read"); + } + return "Stable metadata description."; + }, + get risk() { + riskReads += 1; + if (riskReads > 1) { + throw new Error("risk getter re-read"); + } + return "medium"; + }, + get tags() { + tagsReads += 1; + if (tagsReads > 1) { + throw new Error("tags getter re-read"); + } + return ["metadata", "fixture"]; + }, + } as PluginToolMetadataRegistration); + }, + }); + setActivePluginRegistry(registry.registry); + + const tool = { + name: "volatile_tool", + label: "Raw label", + description: "Raw description", + parameters: { type: "object", properties: {} }, + execute: async () => ({ text: "ok" }), + } as unknown as AnyAgentTool; + setPluginToolMeta(tool, { pluginId: "volatile-metadata", optional: false }); + + expect(buildEffectiveToolInventoryEntries([tool])).toEqual([ + { + id: "volatile_tool", + label: "Volatile Tool", + description: "Stable metadata description.", + rawDescription: "Stable metadata description.", + source: "plugin", + pluginId: "volatile-metadata", + risk: "medium", + tags: ["metadata", "fixture"], + }, + ]); + expect(toolNameReads).toBe(1); + expect(displayNameReads).toBe(1); + expect(descriptionReads).toBe(1); + expect(riskReads).toBe(1); + expect(tagsReads).toBe(1); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index c81ebce60053..ee16e2f40681 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -2274,8 +2274,48 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const readToolMetadataFields = ( + record: PluginRecord, + metadata: PluginToolMetadataRegistration, + ): + | { + toolName: unknown; + displayName: unknown; + description: unknown; + risk: unknown; + tags: unknown; + } + | undefined => { + let toolName: unknown; + try { + toolName = metadata.toolName; + return { + toolName, + displayName: metadata.displayName, + description: metadata.description, + risk: metadata.risk, + tags: metadata.tags, + }; + } catch (error) { + const normalizedToolName = normalizeOptionalHostHookString(toolName); + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: + `tool metadata registration has unreadable fields` + + `${normalizedToolName ? `: ${normalizedToolName}` : ""}: ${formatErrorMessage(error)}`, + }); + return undefined; + } + }; + const registerToolMetadata = (record: PluginRecord, metadata: PluginToolMetadataRegistration) => { - const toolName = normalizeHostHookString(metadata.toolName); + const fields = readToolMetadataFields(record, metadata); + if (!fields) { + return; + } + const toolName = normalizeHostHookString(fields.toolName); if (!toolName) { pushDiagnostic({ level: "error", @@ -2317,14 +2357,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - const displayName = normalizeOptionalHostHookString(metadata.displayName); - const description = normalizeOptionalHostHookString(metadata.description); - const tags = normalizeHostHookStringList(metadata.tags); + const displayName = normalizeOptionalHostHookString(fields.displayName); + const description = normalizeOptionalHostHookString(fields.description); + const tags = normalizeHostHookStringList(fields.tags); + const risk = fields.risk; if ( displayName === "" || description === "" || tags === null || - (metadata.risk !== undefined && !["low", "medium", "high"].includes(metadata.risk)) + (risk !== undefined && + (typeof risk !== "string" || !["low", "medium", "high"].includes(risk))) ) { pushDiagnostic({ level: "error", @@ -2338,10 +2380,12 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginId: record.id, pluginName: record.name, metadata: { - ...metadata, toolName, ...(displayName !== undefined ? { displayName } : {}), ...(description !== undefined ? { description } : {}), + ...(risk !== undefined + ? { risk: risk as NonNullable } + : {}), ...(tags !== undefined ? { tags } : {}), }, source: record.source,