diff --git a/CHANGELOG.md b/CHANGELOG.md index 3334835f960..2c8c3ddde82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/tools: mark manifest-optional sibling tools as optional even when they come from a shared non-optional factory, so cached/status/MCP metadata keeps opt-in tool policy accurate. Thanks @vincentkoc. - Matrix: keep `streaming.progress.toolProgress` scoped to progress draft mode, so partial and quiet Matrix previews do not lose tool progress unless `streaming.preview.toolProgress` is disabled. Thanks @vincentkoc. - Channels/streaming: keep `streaming.progress.toolProgress` scoped to progress draft mode, so disabling compact progress lines does not silence partial/block preview tool updates. Thanks @vincentkoc. - Plugins/update: treat OpenClaw stable correction versions like `2026.5.3-1` as stable releases for npm installs, plugin updates, and bundled-version comparisons, so `latest` can advance official plugins without prerelease opt-in. Thanks @vincentkoc. diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 07c29ace81f..e1de9a25dae 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -32,6 +32,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ let resolvePluginTools: typeof import("./tools.js").resolvePluginTools; let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded; let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey; +let getPluginToolMeta: typeof import("./tools.js").getPluginToolMeta; let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache; let getActivePluginRegistry: typeof import("./runtime.js").getActivePluginRegistry; let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry; @@ -410,6 +411,7 @@ describe("resolvePluginTools optional tools", () => { ({ buildPluginToolMetadataKey, ensureStandalonePluginToolRegistryLoaded, + getPluginToolMeta, resetPluginToolFactoryCache, resolvePluginTools, } = await import("./tools.js")); @@ -1270,6 +1272,70 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); + it("marks allowlisted manifest-optional sibling tools from non-optional factories as optional", () => { + const config = createContext().config; + installToolManifestSnapshot({ + config, + plugin: { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["other_tool", "optional_tool"], + }, + toolMetadata: { + optional_tool: { + optional: true, + }, + }, + }, + }); + const factory = vi.fn(() => [makeTool("other_tool"), makeTool("optional_tool")]); + setActivePluginRegistry( + createToolRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool", "optional_tool"], + declaredNames: ["other_tool", "optional_tool"], + factory, + }, + ]) as never, + "test-tool-registry", + "gateway-bindable", + "/tmp", + ); + + const first = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + config, + }, + toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"], + }), + ); + const second = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + config, + }, + toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"], + }), + ); + + expectResolvedToolNames(first, ["other_tool", "optional_tool"]); + expectResolvedToolNames(second, ["other_tool", "optional_tool"]); + expect(getPluginToolMeta(first[0])?.optional).toBe(false); + expect(getPluginToolMeta(first[1])?.optional).toBe(true); + expect(getPluginToolMeta(second[1])?.optional).toBe(true); + expect(factory).toHaveBeenCalledTimes(1); + }); + it("rejects plugin id collisions with core tool names", () => { const registry = setRegistry([ { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 08f5226464d..6d67e608908 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -128,6 +128,17 @@ function isManifestToolOptional(plugin: PluginManifestRecord, toolName: string): return plugin.toolMetadata?.[toolName]?.optional === true; } +function isPluginToolOptional(params: { + entry: PluginToolRegistration; + manifestPlugin: PluginManifestRecord | undefined; + toolName: string; +}): boolean { + return ( + params.entry.optional || + (params.manifestPlugin ? isManifestToolOptional(params.manifestPlugin, params.toolName) : false) + ); +} + function isOptionalToolAllowed(params: { toolName: string; pluginId: string; @@ -1162,9 +1173,14 @@ export function resolvePluginTools(params: { normalizedNameSet.add(normalizedToolName); existing.add(tool.name); existingNormalized.add(normalizedToolName); + const optional = isPluginToolOptional({ + entry, + manifestPlugin, + toolName: tool.name, + }); pluginToolMeta.set(tool, { pluginId: entry.pluginId, - optional: entry.optional, + optional, }); if (manifestPlugin) { const capturedDescriptors = capturedDescriptorsByPluginId.get(entry.pluginId) ?? []; @@ -1172,7 +1188,7 @@ export function resolvePluginTools(params: { capturePluginToolDescriptor({ pluginId: entry.pluginId, tool, - optional: entry.optional, + optional, }), ); capturedDescriptorsByPluginId.set(entry.pluginId, capturedDescriptors);