From 5e9135f2e23d40914ab3c3ec9f883bcda330fcda Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 12:23:55 +0100 Subject: [PATCH] fix: keep active memory tools available --- CHANGELOG.md | 1 + .../src/providers/mock-openai/server.test.ts | 2 +- .../src/providers/mock-openai/server.ts | 23 ++ src/plugins/tools.optional.test.ts | 226 +++++++++++++++++- src/plugins/tools.ts | 65 ++++- 5 files changed, 301 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a30cea62ee..a0f2831f22e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art. - Plugins/externalization: keep official ACPX, Google Chat, and LINE install specs on production package names, leaving beta-tag probing to the explicit OpenClaw beta update channel. Thanks @vincentkoc. - CLI/doctor: keep missing-plugin repair from overriding official catalog metadata with runtime fallbacks, so ACPX repairs preserve the official npm spec during the externalization rollout. Thanks @vincentkoc. - Plugins/catalog: preserve ClawHub install specs when generating the packaged channel catalog so future storepack-first channel plugins keep their remote source instead of becoming npm-only. Thanks @vincentkoc. diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 740ca768027..df3a36c4c4d 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -1210,7 +1210,7 @@ describe("qa mock openai server", () => { }), }); expect(activeMemorySearch.status).toBe(200); - expect(await activeMemorySearch.text()).toContain('"name":"memory_recall"'); + expect(await activeMemorySearch.text()).toContain('"name":"memory_search"'); const activeMemoryStreamSummary = await fetch(`${server.baseUrl}/v1/responses`, { method: "POST", diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 1999dd8beac..f082207c583 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -1465,6 +1465,12 @@ async function buildResponsesPayload( /silent snack recall check/i.test(allInputText) ) { if (!toolOutput) { + if (!hasDeclaredTool(body, "memory_recall")) { + return buildToolCallEventsWithArgs("memory_search", { + query: "QA movie night snack lemon pepper wings blue cheese", + maxResults: 3, + }); + } return buildToolCallEventsWithArgs("memory_recall", { query: "QA movie night snack lemon pepper wings blue cheese", limit: 3, @@ -1490,6 +1496,23 @@ async function buildResponsesPayload( } return buildAssistantEvents("NONE"); } + const results = Array.isArray(toolJson?.results) + ? (toolJson.results as Array>) + : []; + const first = results[0]; + if (typeof first?.path === "string") { + const from = + typeof first.startLine === "number" + ? Math.max(1, first.startLine) + : typeof first.endLine === "number" + ? Math.max(1, first.endLine) + : 1; + return buildToolCallEventsWithArgs("memory_get", { + path: first.path, + from, + lines: 4, + }); + } const memorySnippet = Array.isArray(toolJson?.results) ? JSON.stringify(toolJson.results) : toolOutput; diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 7c25ecf220f..ae9a4f8bfe2 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -389,7 +389,7 @@ describe("resolvePluginTools optional tools", () => { }); beforeEach(() => { - loadOpenClawPluginsMock.mockClear(); + loadOpenClawPluginsMock.mockReset(); resolveRuntimePluginRegistryMock.mockReset(); resolveRuntimePluginRegistryMock.mockImplementation((params) => loadOpenClawPluginsMock(params), @@ -1185,6 +1185,217 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); + it("does not let disabled bundled tool owners poison explicit runtime allowlists", () => { + const config = { + plugins: { + enabled: true, + allow: ["memory-core", "memory-lancedb"], + load: { paths: [] }, + entries: { + "memory-core": { enabled: true }, + "memory-lancedb": { enabled: false }, + }, + slots: { memory: "memory-core" }, + }, + }; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "memory-core", + origin: "bundled", + enabledByDefault: false, + channels: [], + providers: [], + contracts: { + tools: ["memory_get", "memory_search"], + }, + }, + { + id: "memory-lancedb", + origin: "bundled", + enabledByDefault: false, + channels: [], + providers: [], + contracts: { + tools: ["memory_recall"], + }, + }, + ], + }); + const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]); + const activeRegistry = { + plugins: [ + { id: "memory-core", status: "loaded" }, + { id: "memory-lancedb", status: "disabled" }, + ], + tools: [ + { + pluginId: "memory-core", + optional: false, + source: "/tmp/memory-core.js", + names: ["memory_search", "memory_get"], + declaredNames: ["memory_search", "memory_get"], + factory: memorySearchFactory, + }, + ], + diagnostics: [], + }; + setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), config }, + toolAllowlist: ["memory_recall", "memory_search", "memory_get"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["memory_search", "memory_get"]); + expect(memorySearchFactory).toHaveBeenCalledTimes(1); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("falls back from a loaded channel registry without matching tool entries", () => { + const config = { + plugins: { + enabled: true, + allow: ["memory-core"], + load: { paths: [] }, + entries: { + "memory-core": { enabled: true }, + }, + slots: { memory: "memory-core" }, + }, + }; + installToolManifestSnapshot({ + config, + plugin: { + id: "memory-core", + origin: "bundled", + enabledByDefault: false, + channels: [], + providers: [], + contracts: { + tools: ["memory_get", "memory_search"], + }, + }, + }); + const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]); + const activeRegistry = { + plugins: [{ id: "memory-core", status: "loaded" }], + tools: [ + { + pluginId: "memory-core", + optional: false, + source: "/tmp/memory-core.js", + names: ["memory_search", "memory_get"], + declaredNames: ["memory_search", "memory_get"], + factory: memorySearchFactory, + }, + ], + diagnostics: [], + }; + setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); + pinActivePluginChannelRegistry({ + plugins: [{ id: "memory-core", status: "loaded" }], + tools: [], + diagnostics: [], + } as never); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), config }, + toolAllowlist: ["memory_search", "memory_get"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["memory_search", "memory_get"]); + expect(memorySearchFactory).toHaveBeenCalledTimes(1); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("loads a standalone registry when cached runtime registries lack matching tool entries", () => { + const config = { + plugins: { + enabled: true, + allow: ["memory-core"], + load: { paths: [] }, + entries: { + "memory-core": { enabled: true }, + }, + slots: { memory: "memory-core" }, + }, + }; + installToolManifestSnapshot({ + config, + plugin: { + id: "memory-core", + origin: "bundled", + enabledByDefault: false, + channels: [], + providers: [], + contracts: { + tools: ["memory_get", "memory_search"], + }, + }, + }); + const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]); + const loadedRegistry = { + plugins: [{ id: "memory-core", status: "loaded" }], + tools: [ + { + pluginId: "memory-core", + optional: false, + source: "/tmp/memory-core.js", + names: ["memory_search", "memory_get"], + declaredNames: ["memory_search", "memory_get"], + factory: memorySearchFactory, + }, + ], + diagnostics: [], + }; + setActivePluginRegistry( + { + plugins: [{ id: "memory-core", status: "loaded" }], + tools: [], + diagnostics: [], + } as never, + "gateway-startup", + "gateway-bindable", + "/tmp", + ); + pinActivePluginChannelRegistry({ + plugins: [{ id: "memory-core", status: "loaded" }], + tools: [], + diagnostics: [], + } as never); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockReturnValue(loadedRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), config }, + toolAllowlist: ["memory_search", "memory_get"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["memory_search", "memory_get"]); + expect(memorySearchFactory).toHaveBeenCalledTimes(1); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + activate: false, + onlyPluginIds: ["memory-core"], + toolDiscovery: true, + }), + ); + }); + it("adds enabled non-startup tool plugins to the active tool runtime scope", () => { const activeRegistry = createOptionalDemoActiveRegistry(); setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp"); @@ -1207,7 +1418,18 @@ describe("resolvePluginTools optional tools", () => { allowGatewaySubagentBinding: true, }); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: expect.arrayContaining(["tavily"]), + toolDiscovery: true, + }), + ); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: expect.arrayContaining(["tavily"]), + toolDiscovery: true, + }), + ); }); it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 6a7fb0fee74..8a25bd8bab0 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -11,7 +11,7 @@ import { import type { PluginManifestRecord } from "./manifest-registry.js"; import { hasManifestToolAvailability } from "./manifest-tool-availability.js"; import type { PluginMetadataManifestView } from "./plugin-metadata-snapshot.types.js"; -import type { PluginToolRegistration } from "./registry-types.js"; +import type { PluginRegistry, PluginToolRegistration } from "./registry-types.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, @@ -345,6 +345,7 @@ function resolvePluginToolRuntimePluginIds(params: { }): string[] { const pluginIds = new Set(); const allowlist = normalizeAllowlist(params.toolAllowlist); + const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); const snapshot = params.snapshot ?? loadManifestContractSnapshot({ @@ -362,6 +363,12 @@ function resolvePluginToolRuntimePluginIds(params: { ) { continue; } + if ( + normalizedPlugins.entries[plugin.id]?.enabled === false || + normalizedPlugins.deny.includes(plugin.id) + ) { + continue; + } const toolNames = plugin.contracts?.tools ?? []; if ( manifestToolContractMatchesAllowlist({ @@ -620,18 +627,50 @@ function resolvePluginToolRegistry(params: { workspaceDir: params.loadOptions.workspaceDir, requiredPluginIds: params.onlyPluginIds, }; - return ( - getLoadedRuntimePluginRegistry({ - ...lookup, - surface: "channel", - }) ?? - getLoadedRuntimePluginRegistry({ - env: lookup.env, - workspaceDir: lookup.workspaceDir, - requiredPluginIds: lookup.requiredPluginIds, - surface: "active", - }) - ); + const channelRegistry = getLoadedRuntimePluginRegistry({ + ...lookup, + surface: "channel", + }); + if (registryHasScopedPluginTools(channelRegistry, params.onlyPluginIds)) { + return channelRegistry; + } + + const activeRegistry = getLoadedRuntimePluginRegistry({ + env: lookup.env, + workspaceDir: lookup.workspaceDir, + requiredPluginIds: lookup.requiredPluginIds, + surface: "active", + }); + if (registryHasScopedPluginTools(activeRegistry, params.onlyPluginIds)) { + return activeRegistry; + } + + const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({ + surface: "active", + requiredPluginIds: params.onlyPluginIds, + loadOptions: params.loadOptions, + }); + if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) { + return standaloneRegistry; + } + return channelRegistry ?? activeRegistry ?? standaloneRegistry; +} + +function registryHasScopedPluginTools( + registry: PluginRegistry | undefined, + pluginIds: readonly string[] | undefined, +): registry is PluginRegistry { + if (!registry) { + return false; + } + if (pluginIds === undefined) { + return (registry.tools?.length ?? 0) > 0; + } + const scopedPluginIds = new Set(pluginIds); + if (scopedPluginIds.size === 0) { + return true; + } + return registry.tools.some((entry) => scopedPluginIds.has(entry.pluginId)); } function resolvePluginToolLoadState(params: {