diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd5a01d0f6..5ccab638189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX. - Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001. - Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc. +- CLI/plugins: reject unowned command roots such as `openclaw foo` before managed proxy startup and full plugin CLI runtime loading while preserving manifest-owned and CLI-metadata-owned plugin commands. Fixes #75287. Thanks @neilofneils404. - Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc. - Plugins/tools: cold-load selected plugin tool registries when the active registry only has partial tool coverage, so wildcard-expanded allowlists no longer hide installed plugin tools from `tools.effective`. Fixes #76780. Thanks @lilesjtu. - Plugins/tools: compare cached and runtime plugin tool name conflicts with normalized core tool names, so case variants of core tools are blocked instead of leaking duplicate tool registrations. Thanks @vincentkoc. diff --git a/extensions/qa-lab/cli-metadata.ts b/extensions/qa-lab/cli-metadata.ts new file mode 100644 index 00000000000..30be0bf9da0 --- /dev/null +++ b/extensions/qa-lab/cli-metadata.ts @@ -0,0 +1,18 @@ +import { definePluginEntry } from "openclaw/plugin-sdk/core"; + +export default definePluginEntry({ + id: "qa-lab", + name: "QA Lab", + description: "Private QA automation harness and debugger UI", + register(api) { + api.registerCli(() => {}, { + descriptors: [ + { + name: "qa", + description: "Run QA scenarios and launch the private QA debugger UI", + hasSubcommands: true, + }, + ], + }); + }, +}); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index a219272e88a..50951fad425 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -20,6 +20,7 @@ const getProgramContextMock = vi.hoisted(() => vi.fn(() => null)); const registerCoreCliByNameMock = vi.hoisted(() => vi.fn()); const registerSubCliByNameMock = vi.hoisted(() => vi.fn()); const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => ({}))); +const resolvePluginCliRootOwnerIdsMock = vi.hoisted(() => vi.fn()); const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false)); const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn()); @@ -156,6 +157,10 @@ vi.mock("../plugins/cli.js", () => ({ registerPluginCliCommandsFromValidatedConfig: registerPluginCliCommandsFromValidatedConfigMock, })); +vi.mock("../plugins/cli-registry-loader.js", () => ({ + resolvePluginCliRootOwnerIds: resolvePluginCliRootOwnerIdsMock, +})); + vi.mock("../terminal/restore.js", () => ({ restoreTerminalState: restoreTerminalStateMock, })); @@ -218,6 +223,10 @@ describe("runCli exit behavior", () => { startProxyMock.mockResolvedValue(null); stopProxyMock.mockResolvedValue(undefined); getProgramContextMock.mockReturnValue(null); + resolvePluginCliRootOwnerIdsMock.mockImplementation( + ({ primaryCommand }: { primaryCommand?: string }) => + primaryCommand === "googlemeet" ? ["google-meet"] : [], + ); delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH; delete process.env.OPENCLAW_HIDE_BANNER; }); @@ -339,7 +348,7 @@ describe("runCli exit behavior", () => { ["channel capabilities probe", ["node", "openclaw", "channels", "capabilities"]], ["directory plugin command", ["node", "openclaw", "directory", "peers", "list"]], ["message plugin command", ["node", "openclaw", "message", "send", "--to", "demo"]], - ["unknown plugin command", ["node", "openclaw", "googlemeet", "login"]], + ["metadata-owned plugin command", ["node", "openclaw", "googlemeet", "login"]], ])("starts managed proxy routing for %s", (_name, argv) => { expect(shouldStartProxyForCli(argv)).toBe(true); }); @@ -381,7 +390,7 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); - it("starts the managed proxy for unknown plugin commands by default", async () => { + it("starts the managed proxy for metadata-owned plugin commands by default", async () => { tryRouteCliMock.mockResolvedValueOnce(true); await runCli(["node", "openclaw", "googlemeet", "login"]); @@ -389,6 +398,17 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); + it("rejects unowned command roots before proxy and plugin runtime registration", async () => { + await expect(runCli(["node", "openclaw", "foo"])).rejects.toThrow( + 'No built-in command or plugin CLI metadata owns "foo"', + ); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); + }); + it("does not install the env proxy dispatcher for bypassed skills inspection commands", async () => { hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true); tryRouteCliMock.mockResolvedValueOnce(true); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 84e703ac919..7024d96c8f9 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -13,6 +13,7 @@ import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-com import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { + isReservedNonPluginCommandRoot, shouldRegisterPrimaryCommandOnly, shouldSkipPluginCommandRegistration, } from "./command-registration-policy.js"; @@ -22,6 +23,8 @@ import { consumeGatewayRunOptionToken, } from "./gateway-run-argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; +import { getCoreCliCommandNames } from "./program/core-command-descriptors.js"; +import { getSubCliEntries } from "./program/subcli-descriptors.js"; import { resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy, rewriteUpdateFlagArgv, @@ -263,6 +266,52 @@ function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process. }); } +function isKnownBuiltInCommandRoot(primary: string): boolean { + return ( + getCoreCliCommandNames().includes(primary) || + getSubCliEntries().some((entry) => entry.name === primary) + ); +} + +async function isPluginCliRoot(params: { + primary: string; + config: OpenClawConfig; +}): Promise { + try { + const { resolvePluginCliRootOwnerIds } = await import("../plugins/cli-registry-loader.js"); + const ownerIds = await resolvePluginCliRootOwnerIds({ + cfg: params.config, + env: process.env, + primaryCommand: params.primary, + }); + return ownerIds === null ? null : ownerIds.length > 0; + } catch { + return null; + } +} + +async function resolveUnownedCliPrimary(params: { + argv: string[]; + config: OpenClawConfig; +}): Promise { + const invocation = resolveCliArgvInvocation(rewriteUpdateFlagArgv(params.argv)); + const { primary } = invocation; + if ( + invocation.hasHelpOrVersion || + !primary || + primary === "help" || + isReservedNonPluginCommandRoot(primary) || + isKnownBuiltInCommandRoot(primary) + ) { + return null; + } + const pluginRoot = await isPluginCliRoot({ primary, config: params.config }); + if (pluginRoot !== false) { + return null; + } + return primary; +} + async function bootstrapCliProxyCaptureAndDispatcher( startupTrace: ReturnType, options: { ensureDispatcher?: boolean } = {}, @@ -329,8 +378,17 @@ export async function runCli(argv: string[] = process.argv) { // Activate operator-managed proxy routing for network-capable commands. // Local Gateway/control-plane commands keep direct loopback access while - // runtime, provider, plugin, update, and unknown plugin commands route egress. + // runtime, provider, plugin, update, and manifest/metadata-owned plugin commands route egress. let proxyHandle: ProxyHandle | null = null; + let bestEffortConfigPromise: Promise | null = null; + const readBestEffortCliConfig = async (): Promise => { + if (!bestEffortConfigPromise) { + bestEffortConfigPromise = import("../config/io.js").then(({ readBestEffortConfig }) => + readBestEffortConfig(), + ); + } + return await bestEffortConfigPromise; + }; const stopStartedProxy = async () => { const handle = proxyHandle; proxyHandle = null; @@ -345,11 +403,14 @@ export async function runCli(argv: string[] = process.argv) { handle?.kill("SIGTERM"); }; if (shouldStartProxyForCli(normalizedArgv)) { - const [{ readBestEffortConfig }, { startProxy }] = await Promise.all([ - import("../config/io.js"), - import("../infra/net/proxy/proxy-lifecycle.js"), - ]); - const config = await readBestEffortConfig(); + const config = await readBestEffortCliConfig(); + const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config }); + if (unownedPrimary) { + throw new Error( + `Unknown command: openclaw ${unownedPrimary}. No built-in command or plugin CLI metadata owns "${unownedPrimary}".`, + ); + } + const { startProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); proxyHandle = await startProxy(config?.proxy ?? undefined); } diff --git a/src/entry.ts b/src/entry.ts index f22df4368ce..4e15f468a27 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -232,6 +232,6 @@ async function runMainOrRootHelp(argv: string[]): Promise { "[openclaw] Failed to start CLI:", error instanceof Error ? (error.stack ?? error.message) : error, ); - process.exitCode = 1; + process.exit(1); } } diff --git a/src/plugins/cli-registry-loader.ts b/src/plugins/cli-registry-loader.ts index 44235298bd4..fd1e9026e37 100644 --- a/src/plugins/cli-registry-loader.ts +++ b/src/plugins/cli-registry-loader.ts @@ -1,9 +1,11 @@ import { collectUniqueCommandDescriptors } from "../cli/program/command-descriptor-utils.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { createPluginCliGatewayNodesRuntime } from "./cli-gateway-nodes-runtime.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; import type { PluginRegistry } from "./registry.js"; import { buildPluginRuntimeLoadOptions, @@ -53,20 +55,24 @@ function buildPluginCliLoaderParams( params?: { primaryCommand?: string }, loaderOptions?: PluginCliLoaderOptions, ) { - const onlyPluginIds = resolvePrimaryCommandPluginIds(context, params?.primaryCommand); + const onlyPluginIds = resolvePrimaryCommandManifestPluginIds(context, params?.primaryCommand); return buildPluginRuntimeLoadOptions(context, { ...loaderOptions, - ...(onlyPluginIds.length > 0 ? { onlyPluginIds } : {}), + ...(onlyPluginIds && onlyPluginIds.length > 0 ? { onlyPluginIds } : {}), }); } -function resolvePrimaryCommandPluginIds( +function normalizePluginCliRootName(value: string | undefined): string { + return normalizeLowercaseStringOrEmpty(value); +} + +function resolvePrimaryCommandManifestPluginIds( context: PluginCliLoadContext, primaryCommand: string | undefined, -): string[] { - const normalizedPrimary = primaryCommand?.trim(); +): string[] | undefined { + const normalizedPrimary = normalizePluginCliRootName(primaryCommand); if (!normalizedPrimary) { - return []; + return undefined; } return resolveManifestActivationPluginIds({ trigger: { @@ -79,6 +85,47 @@ function resolvePrimaryCommandPluginIds( }); } +function listPluginCliRootOwnerIds(registry: PluginRegistry, primaryCommand: string): string[] { + const normalizedPrimary = normalizePluginCliRootName(primaryCommand); + if (!normalizedPrimary) { + return []; + } + return [ + ...new Set( + registry.cliRegistrars + .filter((entry) => { + const roots = [ + ...entry.commands, + ...entry.descriptors.map((descriptor) => descriptor.name), + ].map(normalizePluginCliRootName); + return roots.includes(normalizedPrimary); + }) + .map((entry) => entry.pluginId), + ), + ]; +} + +async function resolvePrimaryCommandPluginIds( + context: PluginCliLoadContext, + primaryCommand: string | undefined, + loaderOptions?: PluginCliLoaderOptions, +): Promise { + const normalizedPrimary = normalizePluginCliRootName(primaryCommand); + if (!normalizedPrimary) { + return undefined; + } + const manifestPluginIds = resolvePrimaryCommandManifestPluginIds(context, normalizedPrimary); + if (manifestPluginIds && manifestPluginIds.length > 0) { + return manifestPluginIds; + } + const { registry } = await loadPluginCliMetadataRegistryWithContext( + context, + { primaryCommand: normalizedPrimary }, + loaderOptions, + ); + return listPluginCliRootOwnerIds(registry, normalizedPrimary); +} + export function resolvePluginCliLoadContext(params: { cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -109,13 +156,28 @@ export async function loadPluginCliCommandRegistryWithContext(params: { primaryCommand?: string; loaderOptions?: PluginCliLoaderOptions; }): Promise { - const onlyPluginIds = resolvePrimaryCommandPluginIds(params.context, params.primaryCommand); + let onlyPluginIds: string[] | undefined; + try { + onlyPluginIds = await resolvePrimaryCommandPluginIds( + params.context, + params.primaryCommand, + params.loaderOptions, + ); + } catch { + onlyPluginIds = resolvePrimaryCommandManifestPluginIds(params.context, params.primaryCommand); + } + if (onlyPluginIds && onlyPluginIds.length === 0) { + return { + ...params.context, + registry: createEmptyPluginRegistry(), + }; + } return { ...params.context, registry: loadOpenClawPlugins( buildPluginRuntimeLoadOptions(params.context, { ...params.loaderOptions, - ...(onlyPluginIds.length > 0 ? { onlyPluginIds } : {}), + ...(onlyPluginIds && onlyPluginIds.length > 0 ? { onlyPluginIds } : {}), activate: false, cache: false, runtimeOptions: { @@ -196,6 +258,24 @@ export async function loadPluginCliRegistrationEntries(params: { }); } +export async function resolvePluginCliRootOwnerIds( + params: PluginCliPublicLoadParams, +): Promise { + const primaryCommand = normalizePluginCliRootName(params.primaryCommand); + if (!primaryCommand) { + return null; + } + const logger = resolvePluginCliLogger(params.logger); + const context = resolvePluginCliLoadContext({ + cfg: params.cfg, + env: params.env, + logger, + }); + return ( + (await resolvePrimaryCommandPluginIds(context, primaryCommand, params.loaderOptions)) ?? null + ); +} + export async function loadPluginCliRegistrationEntriesWithDefaults( params: PluginCliPublicLoadParams, ): Promise { diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index c53b7dcb912..1cf8607bf5f 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -407,7 +407,7 @@ describe("registerPluginCliCommands", () => { expect(mocks.memoryListAction).toHaveBeenCalledTimes(1); }); - it("keeps full CLI loading when primary command planning finds no plugin match", async () => { + it("scopes full CLI loading through CLI metadata when manifest planning finds no plugin match", async () => { const program = createProgram(); program.exitOverride(); @@ -416,13 +416,28 @@ describe("registerPluginCliCommands", () => { primary: "memory", }); + expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalled(); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( - expect.not.objectContaining({ - onlyPluginIds: expect.anything(), + expect.objectContaining({ + onlyPluginIds: ["memory-core"], }), ); }); + it("skips full plugin runtime loading when no metadata owns the requested primary", async () => { + const program = createProgram(); + program.exitOverride(); + + await registerPluginCliCommands(program, {} as OpenClawConfig, undefined, undefined, { + mode: "lazy", + primary: "missing-command", + }); + + expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalled(); + expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled(); + expect(program.commands.map((command) => command.name())).not.toContain("missing-command"); + }); + it("returns null for validated plugin CLI config when the snapshot is invalid", async () => { mocks.readConfigFileSnapshot.mockResolvedValueOnce({ valid: false,