diff --git a/CHANGELOG.md b/CHANGELOG.md index 183350853f0..12b583626a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc. - Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc. +- Plugins/tools: cold-load selected plugin tool registries when the active registry only has partial tool coverage, so wildcard-expanded allowlists no longer hide installed plugin tools from `tools.effective`. Fixes #76780. Thanks @lilesjtu. - Plugins/OpenRouter: advertise DeepSeek V4 thinking levels, including `xhigh` and `max`, through the runtime and lightweight provider policy surfaces so `/think` validation no longer rejects OpenRouter-routed DeepSeek V4 models. Fixes #74788. Thanks @vincentkoc. - Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Thanks @vincentkoc. - CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc. diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.ts b/src/plugins/runtime/standalone-runtime-registry-loader.ts index 3bb78ba8e72..978d113b0c4 100644 --- a/src/plugins/runtime/standalone-runtime-registry-loader.ts +++ b/src/plugins/runtime/standalone-runtime-registry-loader.ts @@ -50,23 +50,30 @@ function installStandaloneRegistry( export function ensureStandaloneRuntimePluginRegistryLoaded(params: { loadOptions: PluginLoadOptions; + forceLoad?: boolean; + installRegistry?: boolean; requiredPluginIds?: readonly string[]; surface?: ActiveRuntimePluginRegistrySurface; }): PluginRegistry | undefined { const requiredPluginIds = params.requiredPluginIds ?? params.loadOptions.onlyPluginIds; const surface = params.surface ?? "active"; - const existing = getLoadedRuntimePluginRegistry({ - env: params.loadOptions.env, - loadOptions: params.loadOptions, - workspaceDir: params.loadOptions.workspaceDir, - requiredPluginIds, - surface, - }); - if (existing) { - return existing; + if (!params.forceLoad) { + const existing = getLoadedRuntimePluginRegistry({ + env: params.loadOptions.env, + loadOptions: params.loadOptions, + workspaceDir: params.loadOptions.workspaceDir, + requiredPluginIds, + surface, + }); + if (existing) { + return existing; + } } - const registry = loadOpenClawPlugins(params.loadOptions); + const effectiveLoadOptions = params.forceLoad + ? { ...params.loadOptions, cache: false } + : params.loadOptions; + const registry = loadOpenClawPlugins(effectiveLoadOptions); if (params.loadOptions.activate !== false) { switch (surface) { case "active": @@ -81,6 +88,10 @@ export function ensureStandaloneRuntimePluginRegistryLoaded(params: { return registry; } + if (params.installRegistry === false) { + return registry; + } + installStandaloneRegistry(registry, { loadOptions: params.loadOptions, surface, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index fcb66c1361e..30e6dc367f3 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -32,6 +32,7 @@ let resolvePluginTools: typeof import("./tools.js").resolvePluginTools; let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded; let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey; let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache; +let getActivePluginRegistry: typeof import("./runtime.js").getActivePluginRegistry; let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry; let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest; let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry; @@ -394,8 +395,12 @@ describe("resolvePluginTools optional tools", () => { resetPluginToolFactoryCache, resolvePluginTools, } = await import("./tools.js")); - ({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } = - await import("./runtime.js")); + ({ + getActivePluginRegistry, + pinActivePluginChannelRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, + } = await import("./runtime.js")); ({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } = await import("./current-plugin-metadata-snapshot.js")); }); @@ -553,6 +558,73 @@ describe("resolvePluginTools optional tools", () => { ); }); + it("does not reuse a partial active registry for wildcard-selected plugin tools", () => { + const context = createContext(); + const config = context.config; + const optionalEntry = createOptionalDemoEntry(); + const multiEntry: MockRegistryToolEntry = { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool"], + declaredNames: ["other_tool"], + factory: () => makeTool("other_tool"), + }; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["other_tool"], + }, + }, + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["optional_tool"], + }, + }, + ], + }); + const partialRegistry = createToolRegistry([multiEntry]); + partialRegistry.plugins.push({ id: "optional-demo", status: "loaded" }); + const fullRegistry = createToolRegistry([multiEntry, optionalEntry]); + setActivePluginRegistry?.( + partialRegistry as never, + "partial-test-tool-registry", + "gateway-bindable", + "/tmp", + ); + resolveRuntimePluginRegistryMock.mockReturnValue(partialRegistry); + loadOpenClawPluginsMock.mockReturnValue(fullRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context, + toolAllowlist: ["*", "optional-demo"], + }), + ); + + expectResolvedToolNames(tools, ["other_tool", "optional_tool"]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + activate: false, + cache: false, + onlyPluginIds: ["multi", "optional-demo"], + toolDiscovery: true, + }), + ); + }); + it("warns when cold registry load still does not provide the selected plugin tools", () => { const context = { ...createContext(), @@ -597,6 +669,72 @@ describe("resolvePluginTools optional tools", () => { ); }); + it("uses the fresh cold-loaded registry for diagnostics when partial active registries remain incomplete", () => { + const context = createContext(); + const config = context.config; + const multiEntry: MockRegistryToolEntry = { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool"], + declaredNames: ["other_tool"], + factory: () => makeTool("other_tool"), + }; + const optionalEntry = createOptionalDemoEntry(); + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["other_tool"], + }, + }, + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["optional_tool"], + }, + }, + ], + }); + const staleRegistry = createToolRegistry([multiEntry]); + staleRegistry.plugins.push({ id: "optional-demo", status: "loaded" }); + const freshRegistry = createToolRegistry([optionalEntry]); + freshRegistry.plugins.push({ id: "multi", status: "loaded" }); + setActivePluginRegistry?.( + staleRegistry as never, + "partial-test-tool-registry", + "gateway-bindable", + "/tmp", + ); + resolveRuntimePluginRegistryMock.mockReturnValue(staleRegistry); + loadOpenClawPluginsMock.mockReturnValue(freshRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context, + toolAllowlist: ["*", "optional-demo"], + }), + ); + + expectResolvedToolNames(tools, ["optional_tool"]); + expect(getActivePluginRegistry?.()).toBe(staleRegistry); + expectSingleDiagnosticMessage( + freshRegistry.diagnostics, + "plugin tool registry did not include selected plugin tools after cold load (multi)", + ); + expect(staleRegistry.diagnostics).toEqual([]); + }); + it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => { const config = createContext().config; installToolManifestSnapshot({ @@ -1504,26 +1642,54 @@ describe("resolvePluginTools optional tools", () => { it("adds enabled non-startup tool plugins to the active tool runtime scope", () => { const activeRegistry = createOptionalDemoActiveRegistry(); + const context = createContext(); + const config = { + ...context.config, + plugins: { + ...context.config.plugins, + allow: ["tavily"], + entries: { + tavily: { enabled: true }, + }, + }, + }; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["optional_tool"], + }, + }, + { + id: "tavily", + origin: "bundled", + enabledByDefault: false, + channels: [], + providers: [], + contracts: { + tools: ["tavily_search"], + }, + }, + ], + }); setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + loadOpenClawPluginsMock.mockReturnValue(createToolRegistry([])); resolvePluginTools({ context: { - ...createContext(), - config: { - plugins: { - enabled: true, - allow: ["tavily"], - entries: { - tavily: { enabled: true }, - }, - }, - }, + ...context, + config, } as never, - toolAllowlist: ["optional_tool", "tavily"], + toolAllowlist: ["*", "tavily"], allowGatewaySubagentBinding: true, }); - expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( expect.objectContaining({ onlyPluginIds: expect.arrayContaining(["tavily"]), diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index adddbad4f06..fbb2fd2b37c 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -645,15 +645,18 @@ function resolvePluginToolRegistry(params: { return activeRegistry; } + const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry); const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({ surface: "active", + forceLoad: forceStandaloneLoad, + installRegistry: !forceStandaloneLoad, requiredPluginIds: params.onlyPluginIds, loadOptions: params.loadOptions, }); if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) { return standaloneRegistry; } - return channelRegistry ?? activeRegistry ?? standaloneRegistry; + return standaloneRegistry ?? channelRegistry ?? activeRegistry; } function registryHasScopedPluginTools( @@ -670,7 +673,8 @@ function registryHasScopedPluginTools( if (scopedPluginIds.size === 0) { return true; } - return registry.tools.some((entry) => scopedPluginIds.has(entry.pluginId)); + const registryPluginIds = new Set(registry.tools.map((entry) => entry.pluginId)); + return Array.from(scopedPluginIds).every((pluginId) => registryPluginIds.has(pluginId)); } function resolvePluginToolLoadState(params: {