From 0c46e8000e2b1d32d0d28c1fa63e25f09190a48f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 23:52:51 +0100 Subject: [PATCH] fix(plugins): cache discovery registration snapshots Co-authored-by: junpei.o <14040213+livingghost@users.noreply.github.com> Co-authored-by: Yoshiaki Okuyama Co-authored-by: Shion Eria Co-authored-by: Billy Shih <1472300+bbshih@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/tools/plugin.md | 15 ++ src/plugins/loader.test.ts | 122 +++++++++-- src/plugins/loader.ts | 201 +++++++++++++----- src/plugins/providers.runtime.ts | 2 +- src/plugins/providers.test.ts | 8 +- src/plugins/registry.ts | 54 ++++- src/plugins/types.ts | 20 +- 9 files changed, 345 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1bd8165e1..594cd5216ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu. - +- Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih. ## 2026.4.24 ### Breaking diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f5fe9f6c451..068ea6bb393 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -b4fb88ca434fb92a38bb068cc0b1863b1f22bcde2ce21499c3077ea7e8460775 plugin-sdk-api-baseline.json -0f373c8820c0cd17b13dddf520dd286d9dec85234eb0a7f94dac07432572ede7 plugin-sdk-api-baseline.jsonl +eb5c790aaa54be7b1380eb5a162db50dd314e052aedb5e608290092c33d999f2 plugin-sdk-api-baseline.json +0d2fd80f69e0c3488b6bdbbbb035b08ab108637790d1f30b8e4f84c71c5bc8e2 plugin-sdk-api-baseline.jsonl diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7bb19b60a24..c6f49917513 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -366,6 +366,21 @@ activation. The loader still falls back to `activate(api)` for older plugins, but bundled plugins and new external plugins should treat `register` as the public contract. +`api.registrationMode` tells a plugin why its entry is being loaded: + +| Mode | Meaning | +| --------------- | ------------------------------------------------------------------------------------------------------ | +| `full` | Runtime activation. Register tools, hooks, services, commands, routes, and other live side effects. | +| `discovery` | Read-only capability discovery. Register providers and metadata, but skip expensive live side effects. | +| `setup-only` | Channel setup metadata loading through a lightweight setup entry. | +| `setup-runtime` | Channel setup loading that also needs the runtime entry. | +| `cli-metadata` | CLI command metadata collection only. | + +Plugin entries that open sockets, databases, background workers, or long-lived +clients should guard those side effects with `api.registrationMode === "full"`. +Discovery loads are cached separately from activating loads and do not replace +the running Gateway registry. + Common registration methods: | Method | What it registers | diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 64dd8e9680f..d29929a41d7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3415,13 +3415,107 @@ module.exports = { id: "throws-after-import", register() {} };`, ); }); - it("throws when activate:false is used without cache:false", () => { - expect(() => loadOpenClawPlugins({ activate: false })).toThrow( - "activate:false requires cache:false", - ); - expect(() => loadOpenClawPlugins({ activate: false, cache: true })).toThrow( - "activate:false requires cache:false", - ); + it("uses discovery registration mode for non-activating loads", () => { + useNoBundledPlugins(); + const marker = "__openclawDiscoveryModeTest"; + const plugin = writePlugin({ + id: "discovery-mode-test", + filename: "discovery-mode-test.cjs", + body: `module.exports = { + id: "discovery-mode-test", + register(api) { + globalThis.${marker} = globalThis.${marker} || []; + globalThis.${marker}.push(api.registrationMode); + api.registerProvider({ id: "discovery-provider", label: "Discovery Provider", auth: [] }); + api.registerTool({ + name: "discovery_tool", + description: "Discovery tool", + parameters: {}, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }); + }, + };`, + }); + const config = { + plugins: { + load: { paths: [plugin.file] }, + allow: ["discovery-mode-test"], + }, + }; + + const snapshot = loadOpenClawPlugins({ + activate: false, + cache: false, + workspaceDir: plugin.dir, + config, + }); + expect((globalThis as Record)[marker]).toEqual(["discovery"]); + expect(snapshot.providers.map((entry) => entry.provider.id)).toEqual(["discovery-provider"]); + expect(snapshot.tools.flatMap((entry) => entry.names)).toContain("discovery_tool"); + + loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config, + }); + expect((globalThis as Record)[marker]).toEqual(["discovery", "full"]); + delete (globalThis as Record)[marker]; + }); + + it("caches non-activating snapshots without restoring global side effects", () => { + useNoBundledPlugins(); + clearPluginCommands(); + const marker = "__openclawSnapshotCacheRegisterCount"; + const plugin = writePlugin({ + id: "snapshot-cache", + filename: "snapshot-cache.cjs", + body: `module.exports = { + id: "snapshot-cache", + register(api) { + globalThis.${marker} = (globalThis.${marker} || 0) + 1; + api.registerCommand({ + name: "snapshot-command", + description: "Snapshot command", + handler: async () => ({ text: "ok" }), + }); + }, + };`, + }); + const options = { + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["snapshot-cache"], + }, + }, + onlyPluginIds: ["snapshot-cache"], + }; + + const first = loadOpenClawPlugins(options); + const second = loadOpenClawPlugins(options); + + expect(second).toBe(first); + expect((globalThis as Record)[marker]).toBe(1); + expect(first.commands.map((entry) => entry.command.name)).toEqual(["snapshot-command"]); + expect(getPluginCommandSpecs()).toEqual([]); + + const active = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + config: options.config, + onlyPluginIds: ["snapshot-cache"], + }); + expect(active).not.toBe(first); + expect((globalThis as Record)[marker]).toBe(2); + expect(getPluginCommandSpecs()).toEqual([ + { + name: "snapshot-command", + description: "Snapshot command", + acceptsArgs: false, + }, + ]); + delete (globalThis as Record)[marker]; }); it("re-initializes global hook runner when serving registry from cache", () => { @@ -4061,7 +4155,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }, }, { - label: "rejects duplicate channel ids during plugin registration", + label: "updates duplicate channel ids during same-plugin registration", pluginId: "channel-dup", body: `module.exports = { id: "channel-dup", register(api) { api.registerChannel({ @@ -4103,11 +4197,9 @@ module.exports = { id: "throws-after-import", register() {} };`, } };`, assert: (registry: ReturnType) => { expect(registry.channels.filter((entry) => entry.plugin.id === "demo")).toHaveLength(1); - expectRegistryErrorDiagnostic({ - registry, - pluginId: "channel-dup", - message: "channel already registered: demo (channel-dup)", - }); + expect( + registry.channels.find((entry) => entry.plugin.id === "demo")?.plugin.meta?.label, + ).toBe("Demo Duplicate"); }, }, { @@ -4417,14 +4509,14 @@ module.exports = { id: "throws-after-import", register() {} };`, }, }, { - label: "same plugin can replace its own route", + label: "same plugin can implicitly replace its own route", buildPlugins: () => [ writePlugin({ id: "http-route-replace-self", filename: "http-route-replace-self.cjs", body: `module.exports = { id: "http-route-replace-self", register(api) { api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false }); - api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true }); + api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => true }); } };`, }), ], diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 85e1c8e7d5a..d544204751c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -66,7 +66,7 @@ import { restorePluginInteractiveHandlers, } from "./interactive-registry.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js"; import type { PluginManifestContracts } from "./manifest.js"; import { @@ -124,6 +124,7 @@ import type { OpenClawPluginDefinition, OpenClawPluginModule, PluginLogger, + PluginRegistrationMode, } from "./types.js"; export type PluginLoadResult = PluginRegistry; @@ -808,6 +809,7 @@ function buildCacheKey(params: { runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; pluginSdkResolution?: PluginSdkResolutionPreference; coreGatewayMethodNames?: string[]; + activate?: boolean; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -845,12 +847,13 @@ function buildCacheKey(params: { params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps"; const runtimeSubagentMode = params.runtimeSubagentMode ?? "default"; const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []); + const activationMode = params.activate === false ? "snapshot" : "active"; return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", - })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; + })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`; } function matchesScopedPluginRequest(params: { @@ -933,6 +936,87 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { ); } +type PluginRegistrationPlan = { + /** Public compatibility label passed to plugin register(api). */ + mode: PluginRegistrationMode; + /** Load a setup entry instead of the normal runtime entry. */ + loadSetupEntry: boolean; + /** Setup flow also needs the runtime channel entry for runtime setters/plugin shape. */ + loadSetupRuntimeEntry: boolean; + /** Apply runtime capability policy such as memory-slot selection. */ + runRuntimeCapabilityPolicy: boolean; + /** Register metadata that only belongs to live activation, not discovery snapshots. */ + runFullActivationOnlyRegistrations: boolean; +}; + +/** + * Convert loader intent into explicit behavior flags. + * + * Registration modes are plugin-facing labels; this plan is the internal source + * of truth for which entrypoint to load and which activation-only policies run. + */ +function resolvePluginRegistrationPlan(params: { + canLoadScopedSetupOnlyChannelPlugin: boolean; + scopedSetupOnlyChannelPluginRequested: boolean; + requireSetupEntryForSetupOnlyChannelPlugins: boolean; + enableStateEnabled: boolean; + shouldLoadModules: boolean; + validateOnly: boolean; + shouldActivate: boolean; + manifestRecord: PluginManifestRecord; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + preferSetupRuntimeForChannelPlugins: boolean; +}): PluginRegistrationPlan | null { + if (params.canLoadScopedSetupOnlyChannelPlugin) { + return { + mode: "setup-only", + loadSetupEntry: true, + loadSetupRuntimeEntry: false, + runRuntimeCapabilityPolicy: false, + runFullActivationOnlyRegistrations: false, + }; + } + if ( + params.scopedSetupOnlyChannelPluginRequested && + params.requireSetupEntryForSetupOnlyChannelPlugins + ) { + return null; + } + if (!params.enableStateEnabled) { + return null; + } + const loadSetupRuntimeEntry = + params.shouldLoadModules && + !params.validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: params.manifestRecord.channels, + setupSource: params.manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + params.manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, + cfg: params.cfg, + env: params.env, + preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, + }); + if (loadSetupRuntimeEntry) { + return { + mode: "setup-runtime", + loadSetupEntry: true, + loadSetupRuntimeEntry: true, + runRuntimeCapabilityPolicy: false, + runFullActivationOnlyRegistrations: false, + }; + } + const mode = params.shouldActivate ? "full" : "discovery"; + return { + mode, + loadSetupEntry: false, + loadSetupRuntimeEntry: false, + runRuntimeCapabilityPolicy: true, + runFullActivationOnlyRegistrations: mode === "full", + }; +} + function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const env = options.env ?? process.env; const cfg = applyTestPluginDefaults(options.config ?? {}, env); @@ -976,6 +1060,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { runtimeSubagentMode, pluginSdkResolution: options.pluginSdkResolution, coreGatewayMethodNames, + activate: options.activate, }); return { env, @@ -1053,6 +1138,15 @@ function getCompatibleActivePluginRegistry( if (loadContext.cacheKey === activeCacheKey) { return activeRegistry; } + if (!loadContext.shouldActivate) { + const activatingCacheKey = resolvePluginLoadCacheContext({ + ...options, + activate: true, + }).cacheKey; + if (activatingCacheKey === activeCacheKey) { + return activeRegistry; + } + } if ( loadContext.runtimeSubagentMode === "default" && getActivePluginRuntimeSubagentMode() === "gateway-bindable" @@ -1067,6 +1161,19 @@ function getCompatibleActivePluginRegistry( if (gatewayBindableCacheKey === activeCacheKey) { return activeRegistry; } + if (!loadContext.shouldActivate) { + const activatingGatewayBindableCacheKey = resolvePluginLoadCacheContext({ + ...options, + activate: true, + runtimeOptions: { + ...options.runtimeOptions, + allowGatewaySubagentBinding: true, + }, + }).cacheKey; + if (activatingGatewayBindableCacheKey === activeCacheKey) { + return activeRegistry; + } + } } return undefined; } @@ -1851,13 +1958,6 @@ function activatePluginRegistry( } export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry { - // Snapshot (non-activating) loads must disable the cache to avoid storing a registry - // whose commands were never globally registered. - if (options.activate === false && options.cache !== false) { - throw new Error( - "loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence", - ); - } const { env, cfg, @@ -1882,21 +1982,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); if (cached) { - restoreRegisteredAgentHarnesses(cached.agentHarnesses); - restorePluginCommands(cached.commands ?? []); - restoreRegisteredCompactionProviders(cached.compactionProviders); - restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration); - restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []); - restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); - restoreMemoryPluginState({ - capability: cached.memoryCapability, - corpusSupplements: cached.memoryCorpusSupplements, - promptBuilder: cached.memoryPromptBuilder, - promptSupplements: cached.memoryPromptSupplements, - flushPlanResolver: cached.memoryFlushPlanResolver, - runtime: cached.memoryRuntime, - }); if (shouldActivate) { + restoreRegisteredAgentHarnesses(cached.agentHarnesses); + restorePluginCommands(cached.commands ?? []); + restoreRegisteredCompactionProviders(cached.compactionProviders); + restoreDetachedTaskLifecycleRuntimeRegistration(cached.detachedTaskRuntimeRegistration); + restorePluginInteractiveHandlers(cached.interactiveHandlers ?? []); + restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); + restoreMemoryPluginState({ + capability: cached.memoryCapability, + corpusSupplements: cached.memoryCorpusSupplements, + promptBuilder: cached.memoryPromptBuilder, + promptSupplements: cached.memoryPromptSupplements, + flushPlanResolver: cached.memoryFlushPlanResolver, + runtime: cached.memoryRuntime, + }); activatePluginRegistry( cached.registry, cacheKey, @@ -2178,33 +2278,27 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const scopedSetupOnlyChannelPluginRequested = includeSetupOnlyChannelPlugins && !validateOnly && - onlyPluginIdSet && + Boolean(onlyPluginIdSet) && manifestRecord.channels.length > 0 && (!enableState.enabled || forceSetupOnlyChannelPlugins); const canLoadScopedSetupOnlyChannelPlugin = scopedSetupOnlyChannelPluginRequested && (!requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource)); - const registrationMode = canLoadScopedSetupOnlyChannelPlugin - ? "setup-only" - : scopedSetupOnlyChannelPluginRequested && requireSetupEntryForSetupOnlyChannelPlugins - ? null - : enableState.enabled - ? shouldLoadModules && - !validateOnly && - shouldLoadChannelPluginInSetupRuntime({ - manifestChannels: manifestRecord.channels, - setupSource: manifestRecord.setupSource, - startupDeferConfiguredChannelFullLoadUntilAfterListen: - manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, - cfg, - env, - preferSetupRuntimeForChannelPlugins, - }) - ? "setup-runtime" - : "full" - : null; + const registrationPlan = resolvePluginRegistrationPlan({ + canLoadScopedSetupOnlyChannelPlugin, + scopedSetupOnlyChannelPluginRequested, + requireSetupEntryForSetupOnlyChannelPlugins, + enableStateEnabled: enableState.enabled, + shouldLoadModules, + validateOnly, + shouldActivate, + manifestRecord, + cfg, + env, + preferSetupRuntimeForChannelPlugins, + }); - if (!registrationMode) { + if (!registrationPlan) { record.status = "disabled"; record.error = enableState.reason; markPluginActivationDisabled(record, enableState.reason); @@ -2212,6 +2306,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi seenIds.set(pluginId, candidate.origin); continue; } + const registrationMode = registrationPlan.mode; if (!enableState.enabled) { record.status = "disabled"; record.error = enableState.reason; @@ -2340,7 +2435,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // Exception: the dreaming engine (memory-core by default) must load alongside the // selected memory slot plugin so dreaming can run even when lancedb holds the slot. if ( - registrationMode === "full" && + registrationPlan.runRuntimeCapabilityPolicy && candidate.origin === "bundled" && hasKind(manifestRecord.kind, "memory") ) { @@ -2368,7 +2463,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if (!shouldLoadModules && registrationMode === "full") { + if (!shouldLoadModules && registrationPlan.runRuntimeCapabilityPolicy) { const memoryDecision = resolveMemorySlotDecision({ id: record.id, kind: record.kind, @@ -2414,8 +2509,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } const loadSource = - (registrationMode === "setup-only" || registrationMode === "setup-runtime") && - runtimeSetupSource + registrationPlan.loadSetupEntry && runtimeSetupSource ? runtimeSetupSource : runtimeCandidateSource; const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadSource); @@ -2461,10 +2555,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if ( - (registrationMode === "setup-only" || registrationMode === "setup-runtime") && - manifestRecord.setupSource - ) { + if (registrationPlan.loadSetupEntry && manifestRecord.setupSource) { const setupRegistration = resolveSetupChannelRegistration(mod, { installRuntimeDeps: shouldInstallBundledRuntimeDeps && @@ -2507,7 +2598,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi let mergedSetupRegistration = setupRegistration; let runtimeSetterApplied = false; if ( - registrationMode === "setup-runtime" && + registrationPlan.loadSetupRuntimeEntry && setupRegistration.usesBundledSetupContract && runtimeCandidateSource !== safeSource ) { @@ -2685,7 +2776,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi memorySlotMatched = true; } - if (registrationMode === "full") { + if (registrationPlan.runRuntimeCapabilityPolicy) { if (pluginId !== dreamingEngineId) { const memoryDecision = resolveMemorySlotDecision({ id: record.id, @@ -2711,7 +2802,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } } - if (registrationMode === "full") { + if (registrationPlan.runFullActivationOnlyRegistrations) { if (definition?.reload) { registerReload(record, definition.reload); } diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index e14658e5e0d..346761cf46e 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -231,7 +231,7 @@ function resolveRuntimeProviderPluginLoadState( { onlyPluginIds: providerPluginIds, pluginSdkResolution: params.pluginSdkResolution, - cache: params.cache ?? false, + cache: params.cache ?? true, activate: params.activate ?? false, }, ); diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index d6a39c15075..5918fef70a8 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -164,7 +164,7 @@ function expectLastRuntimeRegistryLoad(params?: { }) { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( expect.objectContaining({ - cache: false, + cache: true, activate: false, ...(params?.env ? { env: params.env } : {}), ...(params?.onlyPluginIds !== undefined ? { onlyPluginIds: params.onlyPluginIds } : {}), @@ -401,7 +401,7 @@ describe("resolvePluginProviders", () => { expect.objectContaining({ workspaceDir: "/workspace/explicit", env, - cache: false, + cache: true, activate: false, }), ); @@ -764,7 +764,7 @@ describe("resolvePluginProviders", () => { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: "/workspace/runtime", - cache: false, + cache: true, activate: false, }), ); @@ -790,7 +790,7 @@ describe("resolvePluginProviders", () => { expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: "/workspace/runtime", - cache: false, + cache: true, activate: false, }), ); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b0f391e3ae6..ce9efb2027d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -196,6 +196,27 @@ const activePluginHookRegistrations = resolveGlobalSingleton< type HookRegistration = { event: string; handler: Parameters[1] }; type HookRollbackEntry = { name: string; previousRegistrations: HookRegistration[] }; +type PluginRegistrationCapabilities = { + /** Broad registry writes that discovery and live activation both need. */ + capabilityHandlers: boolean; + /** Runtime channel registration is suppressed for setup-only metadata loads. */ + runtimeChannel: boolean; +}; + +/** + * Keep mode decoding centralized. PluginRegistrationMode is the public label; + * registry code should consume these booleans instead of duplicating string + * checks across individual registration handlers. + */ +function resolvePluginRegistrationCapabilities( + mode: PluginRegistrationMode, +): PluginRegistrationCapabilities { + return { + capabilityHandlers: mode === "full" || mode === "discovery", + runtimeChannel: mode !== "setup-only", + }; +} + export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); @@ -621,7 +642,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (!existing) { return; } - if (!params.replaceExisting) { + if (!params.replaceExisting && existing.pluginId !== record.id) { pushDiagnostic({ level: "error", pluginId: record.id, @@ -671,6 +692,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registration: OpenClawPluginChannelRegistration | ChannelPlugin, mode: PluginRegistrationMode = "full", ) => { + const registrationCapabilities = resolvePluginRegistrationCapabilities(mode); const normalized = typeof (registration as OpenClawPluginChannelRegistration).plugin === "object" ? (registration as OpenClawPluginChannelRegistration) @@ -686,7 +708,22 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } const id = plugin.id; const existingRuntime = registry.channels.find((entry) => entry.plugin.id === id); - if (mode !== "setup-only" && existingRuntime) { + if (registrationCapabilities.runtimeChannel && existingRuntime) { + if (existingRuntime.pluginId === record.id) { + existingRuntime.plugin = plugin; + existingRuntime.pluginName = record.name; + existingRuntime.source = record.source; + existingRuntime.rootDir = record.rootDir; + const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id); + if (existingSetup) { + existingSetup.plugin = plugin; + existingSetup.pluginName = record.name; + existingSetup.source = record.source; + existingSetup.enabled = record.enabled; + existingSetup.rootDir = record.rootDir; + } + return; + } pushDiagnostic({ level: "error", pluginId: record.id, @@ -697,6 +734,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id); if (existingSetup) { + if (existingSetup.pluginId === record.id) { + existingSetup.plugin = plugin; + existingSetup.pluginName = record.name; + existingSetup.source = record.source; + existingSetup.enabled = record.enabled; + existingSetup.rootDir = record.rootDir; + return; + } pushDiagnostic({ level: "error", pluginId: record.id, @@ -714,7 +759,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { enabled: record.enabled, rootDir: record.rootDir, }); - if (mode === "setup-only") { + if (!registrationCapabilities.runtimeChannel) { return; } registry.channels.push({ @@ -1412,6 +1457,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }, ): OpenClawPluginApi => { const registrationMode = params.registrationMode ?? "full"; + const registrationCapabilities = resolvePluginRegistrationCapabilities(registrationMode); return buildPluginApi({ id: record.id, name: record.name, @@ -1426,7 +1472,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { logger: normalizeLogger(registryParams.logger), resolvePath: (input: string) => resolveUserPath(input), handlers: { - ...(registrationMode === "full" + ...(registrationCapabilities.capabilityHandlers ? { registerTool: (tool, opts) => registerTool(record, tool, opts), registerHook: (events, handler, opts) => diff --git a/src/plugins/types.ts b/src/plugins/types.ts index da3875783bf..ea0d73e09d7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2000,7 +2000,25 @@ export type OpenClawPluginDefinition = { export type OpenClawPluginModule = OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void); -export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata"; +/** + * Public label exposed to plugin `register(api)` calls. + * + * Keep this as a compatibility signal for plugin authors. Loader internals + * should derive explicit capability booleans from the mode instead of branching + * on raw strings throughout the code path. + * + * - `full`: live runtime activation; long-lived side effects may start. + * - `discovery`: read-only capability discovery; skip sockets/workers/clients. + * - `setup-only`: lightweight channel setup entry only. + * - `setup-runtime`: setup flow that also needs the runtime channel entry. + * - `cli-metadata`: CLI command metadata collection. + */ +export type PluginRegistrationMode = + | "full" + | "discovery" + | "setup-only" + | "setup-runtime" + | "cli-metadata"; export type PluginConfigMigration = (config: OpenClawConfig) => | {