fix: reject unowned CLI roots before plugin load (#76379)

Co-authored-by: Neil <neil@neilofneils.com>
This commit is contained in:
neilofneils404
2026-05-03 15:06:49 -04:00
committed by GitHub
parent a64b30705f
commit 904cbec721
7 changed files with 215 additions and 20 deletions

View File

@@ -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.

View File

@@ -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,
},
],
});
},
});

View File

@@ -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);

View File

@@ -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<boolean | null> {
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<string | null> {
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<typeof createGatewayCliMainStartupTrace>,
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<OpenClawConfig> | null = null;
const readBestEffortCliConfig = async (): Promise<OpenClawConfig> => {
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);
}

View File

@@ -232,6 +232,6 @@ async function runMainOrRootHelp(argv: string[]): Promise<void> {
"[openclaw] Failed to start CLI:",
error instanceof Error ? (error.stack ?? error.message) : error,
);
process.exitCode = 1;
process.exit(1);
}
}

View File

@@ -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<string[] | undefined> {
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<PluginCliRegistryLoadResult> {
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<string[] | null> {
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<PluginCliCommandGroupEntry[]> {

View File

@@ -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,