From 4141be5c35e63a6abd28f2db32fe3e9d0cf6afdc Mon Sep 17 00:00:00 2001 From: luoyanglang Date: Sat, 16 May 2026 11:45:54 +0000 Subject: [PATCH] fix(plugins): retain cold-loaded tool registries --- CHANGELOG.md | 1 + .../standalone-runtime-registry-loader.ts | 2 +- src/plugins/tools.optional.test.ts | 45 +++++++++++++++++++ src/plugins/tools.ts | 17 ++++++- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60502de9ea80..f6099701e7bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3118,6 +3118,7 @@ This audited record covers the complete v2026.5.28..v2026.5.31-beta.4 history: 4 - Agents/sandbox: honor explicit Docker sandbox env variables with credential-looking names during container creation, and recreate affected sandbox containers when the effective env policy changes. Fixes #82695. (#82763) Thanks @joshavant. - Plugins: accept deprecated `api.on("deactivate")` registrations as a dated compatibility alias for `gateway_stop`, so external plugin cleanup handlers run on Gateway shutdown while authors get migration guidance. - Plugins: resolve bundled entry, dist-runtime, package-state, and public artifact paths from packaged roots, so bundled plugin probes and hardlinked public surfaces no longer fall back to source files or fail during restart. Fixes #78462. Fixes #75797. Refs #76865. Thanks @ginishuh and @ymebosma. +- Plugins/tools: retain cold-loaded tool registries used by cached descriptors after runtime registry replacement, so contracted plugin tools keep executing in sub-agent sessions instead of failing with `plugin tool runtime missing`. Fixes #80847. - Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged. - Update/doctor: avoid materializing `groupAllowFrom` for channel schemas that reject it, so package-swap doctor repairs do not fail on externalized Slack configs. - Gateway/media: prevent image filenames from overriding generic non-image byte sniffing, so zip/octet-stream payloads mislabeled as images are offloaded or rejected before they become inline image attachments. diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.ts b/src/plugins/runtime/standalone-runtime-registry-loader.ts index 0e1c7d7c5b0a..c7ddbcb8da7c 100644 --- a/src/plugins/runtime/standalone-runtime-registry-loader.ts +++ b/src/plugins/runtime/standalone-runtime-registry-loader.ts @@ -27,7 +27,7 @@ function resolveRuntimeSubagentMode( return "default"; } -function installStandaloneRegistry( +export function installStandaloneRuntimePluginRegistry( registry: PluginRegistry, params: { loadOptions: PluginLoadOptions; diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 57490d8b025c..ec7edb675266 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -2147,6 +2147,51 @@ describe("resolvePluginTools optional tools", () => { expect(factory).toHaveBeenCalledTimes(2); }); + it("retains cold-loaded plugin tools for cached descriptor execution after active registry replacement", async () => { + const factory = vi.fn(() => makeTool("cached_lifecycle_tool")); + const gatewayRegistry = setRegistry([ + { + pluginId: "cache-lifecycle-test", + optional: false, + source: "/tmp/cache-lifecycle-test.js", + names: ["cached_lifecycle_tool"], + factory, + }, + ]); + const first = resolvePluginTools( + createResolveToolsParams({ + toolAllowlist: ["cached_lifecycle_tool"], + allowGatewaySubagentBinding: true, + }), + ); + const [tool] = resolvePluginTools( + createResolveToolsParams({ + toolAllowlist: ["cached_lifecycle_tool"], + allowGatewaySubagentBinding: true, + }), + ); + expectResolvedToolNames(first, ["cached_lifecycle_tool"]); + expect(tool?.name).toBe("cached_lifecycle_tool"); + expect(factory).toHaveBeenCalledTimes(1); + + const replacementRegistry = createToolRegistry([]); + replacementRegistry.plugins.push({ id: "cache-lifecycle-test", status: "loaded" }); + setActivePluginRegistry?.(replacementRegistry as never, "provider-runtime", "default", "/tmp"); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockReset(); + loadOpenClawPluginsMock + .mockReturnValueOnce(gatewayRegistry) + .mockReturnValue(createToolRegistry([])); + + await expect(tool?.execute("call-1", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "ok" }], + }); + await expect(tool?.execute("call-2", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "ok" }], + }); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + it("does not reuse cached plugin tool descriptors across sandbox context changes", () => { const factory = vi.fn((rawCtx: unknown) => { const ctx = rawCtx as { sandboxed?: boolean }; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 9ebc29576766..b468b57040a8 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -24,7 +24,10 @@ import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; -import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js"; +import { + ensureStandaloneRuntimePluginRegistryLoaded, + installStandaloneRuntimePluginRegistry, +} from "./runtime/standalone-runtime-registry-loader.js"; import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import { buildPluginToolDescriptorCacheKey, @@ -925,14 +928,24 @@ function resolvePluginToolRegistry(params: { } const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry); + const shouldRetainColdLoadedToolRegistry = + forceStandaloneLoad && + params.loadOptions.activate === false && + params.loadOptions.toolDiscovery === true; const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({ surface: "active", forceLoad: forceStandaloneLoad, - installRegistry: !forceStandaloneLoad, + installRegistry: shouldRetainColdLoadedToolRegistry ? false : !forceStandaloneLoad, requiredPluginIds: params.onlyPluginIds, loadOptions: params.loadOptions, }); if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) { + if (shouldRetainColdLoadedToolRegistry) { + installStandaloneRuntimePluginRegistry(standaloneRegistry, { + loadOptions: params.loadOptions, + surface: "active", + }); + } return standaloneRegistry; } return standaloneRegistry ?? channelRegistry ?? activeRegistry;