From f187bec815c224d762f3f69034a7e531b5b5722a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 4 Jun 2026 13:03:08 +0200 Subject: [PATCH] fix(cli): skip plugin loader cache clear on short-lived commands --- CHANGELOG.md | 1 + src/cli/hooks-cli.ts | 2 +- src/cli/plugins-cli.install.test.ts | 2 ++ src/cli/plugins-cli.runtime.ts | 4 ++- src/cli/plugins-cli.ts | 2 +- src/cli/plugins-install-command.ts | 21 +++++++++++++++ src/cli/plugins-install-persist.test.ts | 35 +++++++++++++++++++++++++ src/cli/plugins-install-persist.ts | 2 ++ src/cli/plugins-registry-refresh.ts | 5 +++- src/cli/plugins-uninstall-command.ts | 2 ++ src/cli/plugins-update-command.ts | 1 + src/cli/update-cli/update-command.ts | 1 + 12 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce48c890f9f..d66f0d6931c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Agents/Codex/providers/models: release session write locks when prompt-release fence reads fail, retire abandoned Codex app-server startups, keep stream-to-parent ACP spawns registered, close Codex startup clients on timeout, recover bundled provider aliases, avoid custom-provider runtime fanout, preserve provider prompt-cache boundaries, forward Gemini stop sequences, and strip Kimi-incompatible Anthropic cache markers. (#89811) Thanks @takhoffman. - Memory/build/update: warn after startup watcher pressure checks, externalize optional Baileys image backends, restore and pin Canvas A2UI compatibility assets, keep plugin repair fetch failures nonblocking, restore Skill Workshop view switching, and keep the current chat toggle active after awaited session switches. (#89244) Thanks @RomneyDa. - Plugins/auth: keep Hermes migration reports pointed at SQLite auth-profile stores and keep plugin auth-profile reuse tests on the current store path. +- Plugins/CLI: avoid importing the runtime plugin loader only to clear in-process caches after short-lived plugin install, enable, disable, update, and uninstall commands refresh registry metadata. - Security/config/tooling: reject corrupt shell snapshots, suspicious gateway startup configs, malformed release/test/tooling/Docker/perf numeric limits, oversized audit responses, unsafe exec precheck env, and invalid pending-agent SQLite scaffold denials. (#89701, #89705, #89480, #81488) Thanks @RomneyDa and @mmaps. - Release/CI/E2E: restore package changelog extraction after the post-2026.6.1 version bump, keep hydrated pnpm modules under `node_modules` for ARM/Linux package lifecycle scripts, keep OpenAI live-cache prerequisites advisory while Anthropic prerequisites stay blocking, retry Windows Parallels background log appends on transient file-lock errors, bound candidate GitHub and cross-OS Discord fetches, harden ARM smoke/browser checks, show Docker build heartbeats, reset Crabbox pnpm hydrate state, and isolate Testbox/Docker/release journey artifacts. - Release/CI/E2E: keep Crabbox hydrate pnpm stores on the persistent cache volume while still resetting volatile modules, reducing cold installs and runner memory churn. diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index e594e885f6ec..a34cf3fe3979 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -551,7 +551,7 @@ export function registerHooksCli(program: Command): void { defaultRuntime.log( theme.warn("`openclaw hooks install` is deprecated; use `openclaw plugins install`."), ); - await runPluginInstallCommand({ raw, opts }); + await runPluginInstallCommand({ raw, opts, invalidateRuntimeCache: false }); }); hooks diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index a07fbf630208..8567b1e9dba1 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -12,6 +12,7 @@ import { import { applyExclusiveSlotSelection, buildPluginSnapshotReport, + clearPluginRegistryLoadCache, enablePluginInConfig, findBundledPluginSourceMock, installHooksFromNpmSpec, @@ -563,6 +564,7 @@ describe("plugins cli install", () => { expect(replaceConfigCall().nextConfig).toBe(enabledCfg); expect(runtimeLogsContain("slot adjusted")).toBe(true); expect(runtimeLogsContain("Installed plugin: alpha")).toBe(true); + expect(clearPluginRegistryLoadCache).not.toHaveBeenCalled(); }); it("passes force through as overwrite mode for marketplace installs", async () => { diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 356d03f2aacc..fdbcbd0fdd19 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -208,6 +208,7 @@ export async function runPluginsEnableCommand(idInput: string): Promise { await refreshPluginRegistryAfterConfigMutation({ config: next, reason: "policy-changed", + invalidateRuntimeCache: false, policyPluginIds: [enableResult.pluginId], logger: { warn: (message) => defaultRuntime.log(theme.warn(message)), @@ -249,6 +250,7 @@ export async function runPluginsDisableCommand(idInput: string): Promise { await refreshPluginRegistryAfterConfigMutation({ config: next, reason: "policy-changed", + invalidateRuntimeCache: false, policyPluginIds: [id], logger: { warn: (message) => defaultRuntime.log(theme.warn(message)), @@ -265,7 +267,7 @@ export async function runPluginsInstallAction( "install command", async () => { const { runPluginInstallCommand } = await import("./plugins-install-command.js"); - await runPluginInstallCommand({ raw, opts }); + await runPluginInstallCommand({ raw, opts, invalidateRuntimeCache: false }); }, { command: "install" }, ); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 8df98218f0b2..02db06eaaea5 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -134,7 +134,7 @@ export function registerPluginsCli(program: Command) { .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { const { runPluginUninstallCommand } = await import("./plugins-uninstall-command.js"); - await runPluginUninstallCommand(id, opts); + await runPluginUninstallCommand(id, { ...opts, invalidateRuntimeCache: false }); }); plugins diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 64b549bf4ea1..fa1c3b225e38 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -149,6 +149,7 @@ async function installBundledPluginSource(params: { rawSpec: string; bundledSource: BundledPluginSource; warning: string; + invalidateRuntimeCache?: boolean; runtime?: RuntimeEnv; }) { const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId]; @@ -175,6 +176,7 @@ async function installBundledPluginSource(params: { installPath: params.bundledSource.localPath, }, enable: shouldEnable, + invalidateRuntimeCache: params.invalidateRuntimeCache, warningMessage: [params.warning, configWarning].filter(Boolean).join("\n"), runtime: params.runtime, }); @@ -314,6 +316,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { expectedPluginId?: string; expectedIntegrity?: string; trustedSourceLinkedOfficialInstall?: boolean; + invalidateRuntimeCache?: boolean; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromNpmSpec({ @@ -345,6 +348,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { rawSpec: params.spec, bundledSource: bundledFallbackPlan.bundledSource, warning: bundledFallbackPlan.warning, + invalidateRuntimeCache: params.invalidateRuntimeCache, runtime: params.runtime, }); return { ok: true }; @@ -380,6 +384,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { snapshot: params.snapshot, pluginId: result.pluginId, install: installRecord, + invalidateRuntimeCache: params.invalidateRuntimeCache, runtime: params.runtime, }); return { ok: true }; @@ -391,6 +396,7 @@ async function tryInstallPluginFromNpmPackArchive(params: { archivePath: string; safetyOverrides: InstallSafetyOverrides; extensionsDir: string; + invalidateRuntimeCache?: boolean; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromNpmPackArchive({ @@ -428,6 +434,7 @@ async function tryInstallPluginFromNpmPackArchive(params: { ...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}), ...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}), }, + invalidateRuntimeCache: params.invalidateRuntimeCache, runtime: params.runtime, }); return { ok: true }; @@ -439,6 +446,7 @@ async function tryInstallPluginFromGitSpec(params: { spec: string; safetyOverrides: InstallSafetyOverrides; extensionsDir: string; + invalidateRuntimeCache?: boolean; runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromGitSpec({ @@ -466,6 +474,7 @@ async function tryInstallPluginFromGitSpec(params: { gitRef: result.git.ref, gitCommit: result.git.commit, }, + invalidateRuntimeCache: params.invalidateRuntimeCache, runtime: params.runtime, }); return { ok: true }; @@ -573,11 +582,13 @@ export async function runPluginInstallCommand(params: { pin?: boolean; marketplace?: string; }; + invalidateRuntimeCache?: boolean; runtime?: RuntimeEnv; }) { assertConfigWriteAllowedInCurrentMode(); const runtime = params.runtime ?? defaultRuntime; + const invalidateRuntimeCache = params.invalidateRuntimeCache ?? true; const shorthand = !params.opts.marketplace ? await tracePluginLifecyclePhaseAsync( "marketplace shortcut resolution", @@ -685,6 +696,7 @@ export async function runPluginInstallCommand(params: { marketplaceSource: result.marketplaceSource, marketplacePlugin: result.marketplacePlugin, }, + invalidateRuntimeCache, runtime, }); return; @@ -745,6 +757,7 @@ export async function runPluginInstallCommand(params: { installPath: resolved, version: probe.version, }, + invalidateRuntimeCache, successMessage: `Linked plugin path: ${shortenHomePath(resolved)}`, runtime, }); @@ -787,6 +800,7 @@ export async function runPluginInstallCommand(params: { installPath: result.targetDir, version: result.version, }, + invalidateRuntimeCache, runtime, }); return; @@ -819,6 +833,7 @@ export async function runPluginInstallCommand(params: { safetyOverrides, allowBundledFallback: false, extensionsDir, + invalidateRuntimeCache, ...(officialNpmTrust ? { expectedPluginId: officialNpmTrust.pluginId, @@ -850,6 +865,7 @@ export async function runPluginInstallCommand(params: { archivePath: npmPackPath, safetyOverrides, extensionsDir, + invalidateRuntimeCache, runtime, }); if (!npmPackResult.ok) { @@ -865,6 +881,7 @@ export async function runPluginInstallCommand(params: { spec: raw, safetyOverrides, extensionsDir, + invalidateRuntimeCache, runtime, }); if (!gitResult.ok) { @@ -904,6 +921,7 @@ export async function runPluginInstallCommand(params: { rawSpec: raw, bundledSource: bundledPreNpmPlan.bundledSource, warning: bundledPreNpmPlan.warning, + invalidateRuntimeCache, runtime, }), { @@ -943,6 +961,7 @@ export async function runPluginInstallCommand(params: { expectedPluginId: officialExternalPlan.pluginId, expectedIntegrity: officialExternalPlan.expectedIntegrity, trustedSourceLinkedOfficialInstall: true, + invalidateRuntimeCache, runtime, }); if (!npmResult.ok) { @@ -973,6 +992,7 @@ export async function runPluginInstallCommand(params: { spec: raw, installPath: result.targetDir, }, + invalidateRuntimeCache, runtime, }); return; @@ -990,6 +1010,7 @@ export async function runPluginInstallCommand(params: { safetyOverrides, allowBundledFallback: true, extensionsDir, + invalidateRuntimeCache, ...(officialNpmTrust ? { expectedPluginId: officialNpmTrust.pluginId, diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index bdf17949700e..0e1ea5c86e92 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -406,6 +406,41 @@ describe("persistPluginInstall", () => { expectRuntimeLogIncludes("Plugin registry refresh failed"); }); + it("skips runtime cache invalidation when the caller opts out", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + }, + } as OpenClawConfig; + enablePluginInConfig.mockReturnValue({ config: enabledConfig }); + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "alpha", + install: { + source: "npm", + spec: "alpha@1.0.0", + installPath: "/tmp/alpha", + }, + invalidateRuntimeCache: false, + }); + + expect(next).toEqual(enabledConfig); + expect(refreshPluginRegistry).toHaveBeenCalledTimes(1); + expect(clearPluginRegistryLoadCache).not.toHaveBeenCalled(); + }); + it("removes stale denylist entries before enabling installed plugins", async () => { const { persistPluginInstall } = await import("./plugins-install-persist.js"); const baseConfig = { diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index 9680d6234537..7da70f2365ff 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -184,6 +184,7 @@ export async function persistPluginInstall(params: { pluginId: string; install: Omit; enable?: boolean; + invalidateRuntimeCache?: boolean; successMessage?: string; warningMessage?: string; runtime?: RuntimeEnv; @@ -260,6 +261,7 @@ export async function persistPluginInstall(params: { config: next, reason: "source-changed", installRecords: nextInstallRecords, + invalidateRuntimeCache: params.invalidateRuntimeCache, traceCommand: "install", logger: { warn: (message) => runtime.log(theme.warn(message)), diff --git a/src/cli/plugins-registry-refresh.ts b/src/cli/plugins-registry-refresh.ts index 9b6b59f6e780..f8f83356217d 100644 --- a/src/cli/plugins-registry-refresh.ts +++ b/src/cli/plugins-registry-refresh.ts @@ -18,6 +18,7 @@ export async function refreshPluginRegistryAfterConfigMutation(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; installRecords?: Awaited>; + invalidateRuntimeCache?: boolean; policyPluginIds?: readonly string[]; traceCommand?: string; logger?: PluginRegistryRefreshLogger; @@ -46,7 +47,9 @@ export async function refreshPluginRegistryAfterConfigMutation(params: { } catch (error) { params.logger?.warn?.(`Plugin registry refresh failed: ${formatErrorMessage(error)}`); } - await invalidatePluginRuntimeDiscoveryAfterConfigMutation(params); + if (params.invalidateRuntimeCache !== false) { + await invalidatePluginRuntimeDiscoveryAfterConfigMutation(params); + } } async function invalidatePluginRuntimeDiscoveryAfterConfigMutation(params: { diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts index bb7dd61ba095..3759ad40caa2 100644 --- a/src/cli/plugins-uninstall-command.ts +++ b/src/cli/plugins-uninstall-command.ts @@ -17,6 +17,7 @@ export type PluginUninstallOptions = { keepConfig?: boolean; force?: boolean; dryRun?: boolean; + invalidateRuntimeCache?: boolean; }; function isPromptInputClosedError( @@ -194,6 +195,7 @@ export async function runPluginUninstallCommand( config: nextConfig, reason: "source-changed", installRecords: nextInstallRecords, + invalidateRuntimeCache: opts.invalidateRuntimeCache, traceCommand: "uninstall", logger: { warn: (message) => runtime.log(theme.warn(message)), diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index c7c07c84261a..9b5bf1845822 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -142,6 +142,7 @@ export async function runPluginUpdateCommand(params: { config: nextConfig, reason: "source-changed", installRecords: nextPluginInstallRecords, + invalidateRuntimeCache: false, logger, }); } diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 340ef99f8171..4a1050e33eae 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1869,6 +1869,7 @@ export async function updatePluginsAfterCoreUpdate(params: { reason: "source-changed", workspaceDir: params.root, installRecords: nextInstallRecords, + invalidateRuntimeCache: false, logger: pluginLogger, }); }