From 6a3f5d0b1f3536435f8242be0c92eafc9c3a60f6 Mon Sep 17 00:00:00 2001 From: NVIDIAN Date: Sat, 2 May 2026 20:49:14 -0700 Subject: [PATCH] fix(cli): reject missing plugin ids before config writes (#73554) Merged via squash. Prepared head SHA: f0d3e61de26ae542d3626494d3a91a4f586a8f8a Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 1 + src/cli/plugins-cli.policy.test.ts | 82 ++++++++++++++++++++++++++++++ src/cli/plugins-cli.ts | 25 +++++++++ 3 files changed, 108 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed0fd5b0e8..825b296fa85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc. +- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc. - Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206. - Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev. - Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79. diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index 687d9b87d68..e5a88721e60 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -1,19 +1,36 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { + buildPluginRegistrySnapshotReport, enablePluginInConfig, loadConfig, refreshPluginRegistry, resetPluginsCliTestState, + runtimeErrors, runPluginsCommand, writeConfigFile, } from "./plugins-cli-test-helpers.js"; describe("plugins cli policy mutations", () => { + const compatibilityPluginIds = [ + { alias: "openai-codex", pluginId: "openai" }, + { alias: "google-gemini-cli", pluginId: "google" }, + { alias: "minimax-portal-auth", pluginId: "minimax" }, + ] as const; + beforeEach(() => { resetPluginsCliTestState(); }); + function mockPluginRegistry(ids: string[]) { + buildPluginRegistrySnapshotReport.mockReturnValue({ + plugins: ids.map((id) => ({ id })), + diagnostics: [], + registrySource: "derived", + registryDiagnostics: [], + }); + } + it("refreshes the persisted plugin registry after enabling a plugin", async () => { const enabledConfig = { plugins: { @@ -28,6 +45,7 @@ describe("plugins cli policy mutations", () => { enabled: true, pluginId: "alpha", }); + mockPluginRegistry(["alpha"]); await runPluginsCommand(["plugins", "enable", "alpha"]); @@ -48,6 +66,7 @@ describe("plugins cli policy mutations", () => { }, }, } as OpenClawConfig); + mockPluginRegistry(["alpha"]); await runPluginsCommand(["plugins", "disable", "alpha"]); @@ -60,4 +79,67 @@ describe("plugins cli policy mutations", () => { reason: "policy-changed", }); }); + + it.each(compatibilityPluginIds)( + "enables compatibility id $alias through canonical plugin $pluginId", + async ({ alias, pluginId }) => { + const sourceConfig = {} as OpenClawConfig; + const enabledConfig = { + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(sourceConfig); + enablePluginInConfig.mockReturnValue({ + config: enabledConfig, + enabled: true, + }); + mockPluginRegistry([pluginId]); + + await runPluginsCommand(["plugins", "enable", alias]); + + expect(enablePluginInConfig).toHaveBeenCalledWith(sourceConfig, pluginId); + expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig); + }, + ); + + it.each(compatibilityPluginIds)( + "disables compatibility id $alias through canonical plugin $pluginId", + async ({ alias, pluginId }) => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + }, + } as OpenClawConfig); + mockPluginRegistry([pluginId]); + + await runPluginsCommand(["plugins", "disable", alias]); + + const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig; + expect(nextConfig.plugins?.entries?.[pluginId]?.enabled).toBe(false); + expect(nextConfig.plugins?.entries?.[alias]).toBeUndefined(); + }, + ); + + it.each(["enable", "disable"] as const)( + "rejects %s for a plugin that is not discovered", + async (command) => { + mockPluginRegistry(["alpha"]); + + await expect(runPluginsCommand(["plugins", command, "missing-plugin"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors).toContain( + "Plugin not found: missing-plugin. Run `openclaw plugins list` to see installed plugins.", + ); + expect(enablePluginInConfig).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 365062b59c6..0c11b84f7da 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -51,6 +51,17 @@ function formatRegistryState(state: "missing" | "fresh" | "stale"): string { return theme.warn(state); } +function reportMissingPlugin(id: string) { + defaultRuntime.error( + `Plugin not found: ${id}. Run \`openclaw plugins list\` to see installed plugins.`, + ); + return defaultRuntime.exit(1); +} + +function matchesPluginId(plugin: { id: string }, id: string) { + return plugin.id === id; +} + export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -102,12 +113,19 @@ export function registerPluginsCli(program: Command) { .argument("", "Plugin id") .action(async (id: string) => { const { enablePluginInConfig } = await import("../plugins/enable.js"); + const { normalizePluginId } = await import("../plugins/config-state.js"); + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); const { applySlotSelectionForPlugin, logSlotWarnings } = await import("./plugins-command-helpers.js"); const { refreshPluginRegistryAfterConfigMutation } = await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const report = buildPluginRegistrySnapshotReport({ config: cfg }); + id = normalizePluginId(id); + if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) { + return reportMissingPlugin(id); + } const enableResult = enablePluginInConfig(cfg, id); let next: OpenClawConfig = enableResult.config; const slotResult = applySlotSelectionForPlugin(next, id); @@ -141,11 +159,18 @@ export function registerPluginsCli(program: Command) { .description("Disable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { normalizePluginId } = await import("../plugins/config-state.js"); + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); const { setPluginEnabledInConfig } = await import("./plugins-config.js"); const { refreshPluginRegistryAfterConfigMutation } = await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const report = buildPluginRegistrySnapshotReport({ config: cfg }); + id = normalizePluginId(id); + if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) { + return reportMissingPlugin(id); + } const next = setPluginEnabledInConfig(cfg, id, false); await replaceConfigFile({ nextConfig: next,