mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix: reject unowned CLI roots before plugin load (#76379)
Co-authored-by: Neil <neil@neilofneils.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
18
extensions/qa-lab/cli-metadata.ts
Normal file
18
extensions/qa-lab/cli-metadata.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user