CLI: keep root help plugin descriptors non-activating (#57294)

Merged via squash.

Prepared head SHA: c8da48f689
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-29 18:49:57 -04:00
committed by GitHub
parent 1efef8205c
commit e5dac0c39e
21 changed files with 1031 additions and 125 deletions

View File

@@ -7,14 +7,23 @@ import type {
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
OpenClawPluginApi,
OpenClawPluginCliCommandDescriptor,
OpenClawPluginCliRegistrar,
ProviderPlugin,
SpeechProviderPlugin,
WebSearchProviderPlugin,
} from "./types.js";
type CapturedPluginCliRegistration = {
register: OpenClawPluginCliRegistrar;
commands: string[];
descriptors: OpenClawPluginCliCommandDescriptor[];
};
export type CapturedPluginRegistration = {
api: OpenClawPluginApi;
providers: ProviderPlugin[];
cliRegistrars: CapturedPluginCliRegistration[];
cliBackends: CliBackendPlugin[];
speechProviders: SpeechProviderPlugin[];
mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[];
@@ -23,8 +32,12 @@ export type CapturedPluginRegistration = {
tools: AnyAgentTool[];
};
export function createCapturedPluginRegistration(): CapturedPluginRegistration {
export function createCapturedPluginRegistration(params?: {
config?: OpenClawConfig;
registrationMode?: OpenClawPluginApi["registrationMode"];
}): CapturedPluginRegistration {
const providers: ProviderPlugin[] = [];
const cliRegistrars: CapturedPluginCliRegistration[] = [];
const cliBackends: CliBackendPlugin[] = [];
const speechProviders: SpeechProviderPlugin[] = [];
const mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[] = [];
@@ -40,6 +53,7 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration {
return {
providers,
cliRegistrars,
cliBackends,
speechProviders,
mediaUnderstandingProviders,
@@ -50,12 +64,35 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration {
id: "captured-plugin-registration",
name: "Captured Plugin Registration",
source: "captured-plugin-registration",
registrationMode: "full",
config: {} as OpenClawConfig,
registrationMode: params?.registrationMode ?? "full",
config: params?.config ?? ({} as OpenClawConfig),
runtime: {} as PluginRuntime,
logger: noopLogger,
resolvePath: (input) => input,
handlers: {
registerCli(registrar, opts) {
const descriptors = (opts?.descriptors ?? [])
.map((descriptor) => ({
name: descriptor.name.trim(),
description: descriptor.description.trim(),
hasSubcommands: descriptor.hasSubcommands,
}))
.filter((descriptor) => descriptor.name && descriptor.description);
const commands = [
...(opts?.commands ?? []),
...descriptors.map((descriptor) => descriptor.name),
]
.map((command) => command.trim())
.filter(Boolean);
if (commands.length === 0) {
return;
}
cliRegistrars.push({
register: registrar,
commands,
descriptors,
});
},
registerProvider(provider: ProviderPlugin) {
providers.push(provider);
},

View File

@@ -6,11 +6,14 @@ const mocks = vi.hoisted(() => ({
memoryRegister: vi.fn(),
otherRegister: vi.fn(),
memoryListAction: vi.fn(),
loadOpenClawPluginCliRegistry: vi.fn(),
loadOpenClawPlugins: vi.fn(),
applyPluginAutoEnable: vi.fn(),
}));
vi.mock("./loader.js", () => ({
loadOpenClawPluginCliRegistry: (...args: unknown[]) =>
mocks.loadOpenClawPluginCliRegistry(...args),
loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args),
}));
@@ -18,7 +21,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: unknown[]) => mocks.applyPluginAutoEnable(...args),
}));
import { registerPluginCliCommands } from "./cli.js";
import { getPluginCliCommandDescriptors, registerPluginCliCommands } from "./cli.js";
function createProgram(existingCommandName?: string) {
const program = new Command();
@@ -109,6 +112,8 @@ describe("registerPluginCliCommands", () => {
program.command("other").description("Other commands");
});
mocks.memoryListAction.mockReset();
mocks.loadOpenClawPluginCliRegistry.mockReset();
mocks.loadOpenClawPluginCliRegistry.mockResolvedValue(createCliRegistry());
mocks.loadOpenClawPlugins.mockReset();
mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry());
mocks.applyPluginAutoEnable.mockReset();
@@ -150,6 +155,82 @@ describe("registerPluginCliCommands", () => {
);
});
it("loads root-help descriptors through the dedicated non-activating CLI collector", async () => {
const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture();
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
mocks.loadOpenClawPluginCliRegistry.mockResolvedValue({
cliRegistrars: [
{
pluginId: "matrix",
register: vi.fn(),
commands: ["matrix"],
descriptors: [
{
name: "matrix",
description: "Matrix channel utilities",
hasSubcommands: true,
},
],
source: "bundled",
},
{
pluginId: "duplicate-matrix",
register: vi.fn(),
commands: ["matrix"],
descriptors: [
{
name: "matrix",
description: "Duplicate Matrix channel utilities",
hasSubcommands: true,
},
],
source: "bundled",
},
],
});
await expect(getPluginCliCommandDescriptors(rawConfig)).resolves.toEqual([
{
name: "matrix",
description: "Matrix channel utilities",
hasSubcommands: true,
},
]);
expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: autoEnabledConfig,
}),
);
});
it("keeps runtime CLI command registration on the full plugin loader for legacy channel plugins", async () => {
const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture();
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
mocks.loadOpenClawPlugins.mockReturnValue(
createCliRegistry({
memoryCommands: ["legacy-channel"],
memoryDescriptors: [
{
name: "legacy-channel",
description: "Legacy channel commands",
hasSubcommands: true,
},
],
}),
);
await registerPluginCliCommands(createProgram(), rawConfig, undefined, undefined, {
mode: "lazy",
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: autoEnabledConfig,
}),
);
expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled();
});
it("lazy-registers descriptor-backed plugin commands on first invocation", async () => {
const program = createProgram();
program.exitOverride();

View File

@@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import {
loadOpenClawPluginCliRegistry,
loadOpenClawPlugins,
type PluginLoadOptions,
} from "./loader.js";
import type { OpenClawPluginCliCommandDescriptor } from "./types.js";
import type { PluginLogger } from "./types.js";
@@ -30,11 +34,7 @@ function canRegisterPluginCliLazily(entry: {
return entry.commands.every((command) => descriptorNames.has(command));
}
function loadPluginCliRegistry(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
) {
function resolvePluginCliLoadContext(cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) {
const config = cfg ?? loadConfig();
const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config;
const workspaceDir = resolveAgentWorkspaceDir(
@@ -51,22 +51,51 @@ function loadPluginCliRegistry(
config: resolvedConfig,
workspaceDir,
logger,
registry: loadOpenClawPlugins({
config: resolvedConfig,
workspaceDir,
};
}
async function loadPluginCliMetadataRegistry(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
) {
const context = resolvePluginCliLoadContext(cfg, env);
return {
...context,
registry: await loadOpenClawPluginCliRegistry({
config: context.config,
workspaceDir: context.workspaceDir,
env,
logger,
logger: context.logger,
...loaderOptions,
}),
};
}
export function getPluginCliCommandDescriptors(
function loadPluginCliCommandRegistry(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): OpenClawPluginCliCommandDescriptor[] {
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
) {
const context = resolvePluginCliLoadContext(cfg, env);
return {
...context,
registry: loadOpenClawPlugins({
config: context.config,
workspaceDir: context.workspaceDir,
env,
logger: context.logger,
...loaderOptions,
}),
};
}
export async function getPluginCliCommandDescriptors(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): Promise<OpenClawPluginCliCommandDescriptor[]> {
try {
const { registry } = loadPluginCliRegistry(cfg, env);
const { registry } = await loadPluginCliMetadataRegistry(cfg, env);
const seen = new Set<string>();
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
for (const entry of registry.cliRegistrars) {
@@ -91,7 +120,11 @@ export async function registerPluginCliCommands(
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
options?: RegisterPluginCliOptions,
) {
const { config, workspaceDir, logger, registry } = loadPluginCliRegistry(cfg, env, loaderOptions);
const { config, workspaceDir, logger, registry } = loadPluginCliCommandRegistry(
cfg,
env,
loaderOptions,
);
const mode = options?.mode ?? "eager";
const primary = options?.primary ?? null;

View File

@@ -11,6 +11,7 @@ import { createHookRunner } from "./hooks.js";
import {
__testing,
clearPluginLoaderCache,
loadOpenClawPluginCliRegistry,
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
} from "./loader.js";
@@ -2597,6 +2598,361 @@ module.exports = {
expect(registry.channels).toHaveLength(expectedChannels);
});
it("passes validated plugin config into non-activating CLI metadata loads", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "config-cli",
filename: "config-cli.cjs",
body: `module.exports = {
id: "config-cli",
register(api) {
if (!api.pluginConfig || api.pluginConfig.token !== "ok") {
throw new Error("missing plugin config");
}
api.registerCli(() => {}, {
descriptors: [
{
name: "cfg",
description: "Config-backed CLI command",
hasSubcommands: true,
},
],
});
},
};`,
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "config-cli",
configSchema: {
type: "object",
additionalProperties: false,
properties: {
token: { type: "string" },
},
required: ["token"],
},
},
null,
2,
),
"utf-8",
);
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["config-cli"],
entries: {
"config-cli": {
config: {
token: "ok",
},
},
},
},
},
});
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("cfg");
expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded");
});
it("uses the real channel entry in cli-metadata mode for CLI metadata capture", async () => {
useNoBundledPlugins();
const pluginDir = makeTempDir();
const fullMarker = path.join(pluginDir, "full-loaded.txt");
const modeMarker = path.join(pluginDir, "registration-mode.txt");
const runtimeMarker = path.join(pluginDir, "runtime-set.txt");
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/cli-metadata-channel",
openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs" },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "cli-metadata-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["cli-metadata-channel"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core");
require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
...defineChannelPluginEntry({
id: "cli-metadata-channel",
name: "CLI Metadata Channel",
description: "cli metadata channel",
setRuntime() {
require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8");
},
plugin: {
id: "cli-metadata-channel",
meta: {
id: "cli-metadata-channel",
label: "CLI Metadata Channel",
selectionLabel: "CLI Metadata Channel",
docsPath: "/channels/cli-metadata-channel",
blurb: "cli metadata channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
registerCliMetadata(api) {
require("node:fs").writeFileSync(
${JSON.stringify(modeMarker)},
String(api.registrationMode),
"utf-8",
);
api.registerCli(() => {}, {
descriptors: [
{
name: "cli-metadata-channel",
description: "Channel CLI metadata",
hasSubcommands: true,
},
],
});
},
registerFull() {
throw new Error("full channel entry should not run during CLI metadata capture");
},
}),
};`,
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "setup-entry.cjs"),
`throw new Error("setup entry should not load during CLI metadata capture");`,
"utf-8",
);
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
load: { paths: [pluginDir] },
allow: ["cli-metadata-channel"],
},
},
});
expect(fs.existsSync(fullMarker)).toBe(true);
expect(fs.existsSync(runtimeMarker)).toBe(false);
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("cli-metadata");
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
"cli-metadata-channel",
);
});
it("collects channel CLI metadata during full plugin loads", () => {
useNoBundledPlugins();
const pluginDir = makeTempDir();
const modeMarker = path.join(pluginDir, "registration-mode.txt");
const fullMarker = path.join(pluginDir, "full-loaded.txt");
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/full-cli-metadata-channel",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "full-cli-metadata-channel",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["full-cli-metadata-channel"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core");
module.exports = {
...defineChannelPluginEntry({
id: "full-cli-metadata-channel",
name: "Full CLI Metadata Channel",
description: "full cli metadata channel",
plugin: {
id: "full-cli-metadata-channel",
meta: {
id: "full-cli-metadata-channel",
label: "Full CLI Metadata Channel",
selectionLabel: "Full CLI Metadata Channel",
docsPath: "/channels/full-cli-metadata-channel",
blurb: "full cli metadata channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
registerCliMetadata(api) {
require("node:fs").writeFileSync(
${JSON.stringify(modeMarker)},
String(api.registrationMode),
"utf-8",
);
api.registerCli(() => {}, {
descriptors: [
{
name: "full-cli-metadata-channel",
description: "Full-load channel CLI metadata",
hasSubcommands: true,
},
],
});
},
registerFull() {
require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
},
}),
};`,
"utf-8",
);
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [pluginDir] },
allow: ["full-cli-metadata-channel"],
},
},
});
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("full");
expect(fs.existsSync(fullMarker)).toBe(true);
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
"full-cli-metadata-channel",
);
});
it("awaits async plugin registration when collecting CLI metadata", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "async-cli",
filename: "async-cli.cjs",
body: `module.exports = {
id: "async-cli",
async register(api) {
await Promise.resolve();
api.registerCli(() => {}, {
descriptors: [
{
name: "async-cli",
description: "Async CLI metadata",
hasSubcommands: true,
},
],
});
},
};`,
});
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["async-cli"],
},
},
});
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("async-cli");
expect(
registry.diagnostics.some((entry) => entry.message.includes("async registration is ignored")),
).toBe(false);
});
it("applies memory slot gating to non-bundled CLI metadata loads", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "memory-external",
filename: "memory-external.cjs",
body: `module.exports = {
id: "memory-external",
kind: "memory",
register(api) {
api.registerCli(() => {}, {
descriptors: [
{
name: "memory-external",
description: "External memory CLI metadata",
hasSubcommands: true,
},
],
});
},
};`,
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "memory-external",
kind: "memory",
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["memory-external"],
slots: { memory: "memory-other" },
},
},
});
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain(
"memory-external",
);
const memory = registry.plugins.find((entry) => entry.id === "memory-external");
expect(memory?.status).toBe("disabled");
expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"');
});
it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => {
useNoBundledPlugins();
const plugin = writePlugin({

View File

@@ -9,6 +9,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import { clearPluginCommands } from "./command-registry-state.js";
import {
@@ -145,6 +146,37 @@ export function clearPluginLoaderCache(): void {
const defaultLogger = () => createSubsystemLogger("plugins");
function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
return (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const aliasMap = buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
options.pluginSdkResolution,
);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
// Source .ts runtime shims import sibling ".js" specifiers that only exist
// after build. Disable native loading for source entries so Jiti rewrites
// those imports against the source graph, while keeping native dist/*.js
// loading for the canonical built module graph.
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
}
export const __testing = {
buildPluginLoaderJitiOptions,
buildPluginLoaderAliasMap,
@@ -826,36 +858,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
const getJiti = (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
// Pass loader's moduleUrl so the openclaw root can always be resolved even when
// loading external plugins from outside the managed install directory.
const aliasMap = buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
options.pluginSdkResolution,
);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
// Source .ts runtime shims import sibling ".js" specifiers that only exist
// after build. Disable native loading for source entries so Jiti rewrites
// those imports against the source graph, while keeping native dist/*.js
// loading for the canonical built module graph.
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
const getJiti = createPluginJitiLoader(options);
let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null =
null;
@@ -1407,6 +1410,300 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
return registry;
}
export async function loadOpenClawPluginCliRegistry(
options: PluginLoadOptions = {},
): Promise<PluginRegistry> {
const { env, cfg, normalized, onlyPluginIds, cacheKey } = resolvePluginLoadCacheContext({
...options,
activate: false,
cache: false,
});
const logger = options.logger ?? defaultLogger();
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
const getJiti = createPluginJitiLoader(options);
const { registry, registerCli } = createPluginRegistry({
logger,
runtime: {} as PluginRuntime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
suppressGlobalCommands: true,
});
const discovery = discoverOpenClawPlugins({
workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths,
cache: false,
env,
});
const manifestRegistry = loadPluginManifestRegistry({
config: cfg,
workspaceDir: options.workspaceDir,
cache: false,
env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
warnWhenAllowlistIsOpen({
logger,
pluginsEnabled: normalized.enabled,
allow: normalized.allow,
warningCacheKey: `${cacheKey}::cli-metadata`,
discoverablePlugins: manifestRegistry.plugins
.filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id))
.map((plugin) => ({
id: plugin.id,
source: plugin.source,
origin: plugin.origin,
})),
});
const provenance = buildProvenanceIndex({
config: cfg,
normalizedLoadPaths: normalized.loadPaths,
env,
});
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
return compareDuplicateCandidateOrder({
left,
right,
manifestByRoot,
provenance,
env,
});
});
const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null;
for (const candidate of orderedCandidates) {
const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) {
continue;
}
const pluginId = manifestRecord.id;
if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) {
continue;
}
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const record = createPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
format: manifestRecord.format,
bundleFormat: manifestRecord.bundleFormat,
bundleCapabilities: manifestRecord.bundleCapabilities,
source: candidate.source,
rootDir: candidate.rootDir,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: false,
configSchema: Boolean(manifestRecord.configSchema),
});
record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`;
registry.plugins.push(record);
continue;
}
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
enabledByDefault: manifestRecord.enabledByDefault,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
format: manifestRecord.format,
bundleFormat: manifestRecord.bundleFormat,
bundleCapabilities: manifestRecord.bundleCapabilities,
source: candidate.source,
rootDir: candidate.rootDir,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: enableState.enabled,
configSchema: Boolean(manifestRecord.configSchema),
});
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
const pushPluginLoadError = (message: string) => {
record.status = "error";
record.error = message;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
};
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (record.format === "bundle") {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (manifestRecord.kind === "memory") {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: "memory",
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (memoryDecision.selected) {
selectedMemoryPluginId = record.id;
}
}
if (!manifestRecord.configSchema) {
pushPluginLoadError("missing config schema");
continue;
}
const validatedConfig = validatePluginConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
continue;
}
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const opened = openBoundaryFileSync({
absolutePath: candidate.source,
rootPath: pluginRoot,
boundaryLabel: "plugin root",
rejectHardlinks: candidate.origin !== "bundled",
skipLexicalRootCheck: true,
});
if (!opened.ok) {
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
continue;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
let mod: OpenClawPluginModule | null = null;
try {
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
} catch (err) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
error: err,
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
diagnosticMessagePrefix: "failed to load plugin: ",
});
continue;
}
const resolved = resolvePluginModuleExport(mod);
const definition = resolved.definition;
const register = resolved.register;
if (definition?.id && definition.id !== record.id) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
);
continue;
}
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
const manifestKind = record.kind as string | undefined;
const exportKind = definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
});
}
record.kind = definition?.kind ?? record.kind;
if (typeof register !== "function") {
logger.error(`[plugins] ${record.id} missing register/activate export`);
pushPluginLoadError("plugin export missing register/activate");
continue;
}
const api = buildPluginApi({
id: record.id,
name: record.name,
version: record.version,
description: record.description,
source: record.source,
rootDir: record.rootDir,
registrationMode: "cli-metadata",
config: cfg,
pluginConfig: validatedConfig.value,
runtime: {} as PluginRuntime,
logger,
resolvePath: (input) => resolveUserPath(input),
handlers: {
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
},
});
try {
await register(api);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
error: err,
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
diagnosticMessagePrefix: "plugin failed during register: ",
});
}
}
return registry;
}
function safeRealpathOrResolve(value: string): string {
try {
return fs.realpathSync(value);

View File

@@ -988,7 +988,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerWebSearchProvider: (provider) => registerWebSearchProvider(record, provider),
registerGatewayMethod: (method, handler, opts) =>
registerGatewayMethod(record, method, handler, opts),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
registerCliBackend: (backend) => registerCliBackend(record, backend),
registerInteractiveHandler: (registration) => {
@@ -1097,6 +1096,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
}
: {}),
// Allow setup-only/setup-runtime paths to surface parse-time CLI metadata
// without opting into the wider full-registration surface.
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerChannel: (registration) => registerChannel(record, registration, registrationMode),
},
});

View File

@@ -1674,7 +1674,7 @@ export type OpenClawPluginModule =
| OpenClawPluginDefinition
| ((api: OpenClawPluginApi) => void | Promise<void>);
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime";
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata";
/** Main registration API injected into native plugin entry files. */
export type OpenClawPluginApi = {