Plugins: align CLI metadata loader behavior

This commit is contained in:
Gustavo Madeira Santana
2026-03-29 19:20:17 -04:00
parent 9a97c30fad
commit b0077904a7
4 changed files with 174 additions and 28 deletions

View File

@@ -65,6 +65,13 @@ function createCliRegistry(params?: {
};
}
function createEmptyCliRegistry(params?: { diagnostics?: Array<{ message: string }> }) {
return {
cliRegistrars: [],
diagnostics: params?.diagnostics ?? [],
};
}
function expectPluginLoaderConfig(config: OpenClawConfig) {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
@@ -115,7 +122,10 @@ describe("registerPluginCliCommands", () => {
mocks.loadOpenClawPluginCliRegistry.mockReset();
mocks.loadOpenClawPluginCliRegistry.mockResolvedValue(createCliRegistry());
mocks.loadOpenClawPlugins.mockReset();
mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry());
mocks.loadOpenClawPlugins.mockReturnValue({
...createCliRegistry(),
diagnostics: [],
});
mocks.applyPluginAutoEnable.mockReset();
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
});
@@ -231,6 +241,51 @@ describe("registerPluginCliCommands", () => {
expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled();
});
it("falls back to awaited CLI metadata collection when runtime loading ignored async registration", async () => {
const asyncRegistrar = vi.fn(async ({ program }: { program: Command }) => {
const asyncCommand = program.command("async-cli").description("Async CLI");
asyncCommand.command("run").action(mocks.memoryListAction);
});
mocks.loadOpenClawPlugins.mockReturnValue(
createEmptyCliRegistry({
diagnostics: [
{
message: "plugin register returned a promise; async registration is ignored",
},
],
}),
);
mocks.loadOpenClawPluginCliRegistry.mockResolvedValue({
cliRegistrars: [
{
pluginId: "async-plugin",
register: asyncRegistrar,
commands: ["async-cli"],
descriptors: [
{
name: "async-cli",
description: "Async CLI",
hasSubcommands: true,
},
],
source: "bundled",
},
],
diagnostics: [],
});
const program = createProgram();
program.exitOverride();
await registerPluginCliCommands(program, {} as OpenClawConfig, undefined, undefined, {
mode: "lazy",
});
expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledTimes(1);
await program.parseAsync(["async-cli", "run"], { from: "user" });
expect(asyncRegistrar).toHaveBeenCalledTimes(1);
expect(mocks.memoryListAction).toHaveBeenCalledTimes(1);
});
it("lazy-registers descriptor-backed plugin commands on first invocation", async () => {
const program = createProgram();
program.exitOverride();

View File

@@ -11,6 +11,7 @@ import {
loadOpenClawPlugins,
type PluginLoadOptions,
} from "./loader.js";
import type { PluginRegistry } from "./registry.js";
import type { OpenClawPluginCliCommandDescriptor } from "./types.js";
import type { PluginLogger } from "./types.js";
@@ -34,6 +35,28 @@ function canRegisterPluginCliLazily(entry: {
return entry.commands.every((command) => descriptorNames.has(command));
}
function hasIgnoredAsyncPluginRegistration(registry: PluginRegistry): boolean {
return (registry.diagnostics ?? []).some(
(entry) =>
entry.message === "plugin register returned a promise; async registration is ignored",
);
}
function mergeCliRegistrars(params: {
runtimeRegistry: PluginRegistry;
metadataRegistry: PluginRegistry;
}) {
const metadataCommands = new Set(
params.metadataRegistry.cliRegistrars.flatMap((entry) => entry.commands),
);
return [
...params.metadataRegistry.cliRegistrars,
...params.runtimeRegistry.cliRegistrars.filter(
(entry) => !entry.commands.some((command) => metadataCommands.has(command)),
),
];
}
function resolvePluginCliLoadContext(cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) {
const config = cfg ?? loadConfig();
const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config;
@@ -72,22 +95,52 @@ async function loadPluginCliMetadataRegistry(
};
}
function loadPluginCliCommandRegistry(
async function loadPluginCliCommandRegistry(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
) {
const context = resolvePluginCliLoadContext(cfg, env);
return {
...context,
registry: loadOpenClawPlugins({
const runtimeRegistry = loadOpenClawPlugins({
config: context.config,
workspaceDir: context.workspaceDir,
env,
logger: context.logger,
...loaderOptions,
});
if (!hasIgnoredAsyncPluginRegistration(runtimeRegistry)) {
return {
...context,
registry: runtimeRegistry,
};
}
try {
const metadataRegistry = await loadOpenClawPluginCliRegistry({
config: context.config,
workspaceDir: context.workspaceDir,
env,
logger: context.logger,
...loaderOptions,
}),
};
});
return {
...context,
registry: {
...runtimeRegistry,
cliRegistrars: mergeCliRegistrars({
runtimeRegistry,
metadataRegistry,
}),
},
};
} catch (error) {
log.warn(`plugin CLI metadata fallback failed: ${String(error)}`);
return {
...context,
registry: runtimeRegistry,
};
}
}
export async function getPluginCliCommandDescriptors(
@@ -120,7 +173,7 @@ export async function registerPluginCliCommands(
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
options?: RegisterPluginCliOptions,
) {
const { config, workspaceDir, logger, registry } = loadPluginCliCommandRegistry(
const { config, workspaceDir, logger, registry } = await loadPluginCliCommandRegistry(
cfg,
env,
loaderOptions,

View File

@@ -2953,6 +2953,46 @@ module.exports = {
expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"');
});
it("re-evaluates memory slot gating after resolving exported plugin kind", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "memory-export-only",
filename: "memory-export-only.cjs",
body: `module.exports = {
id: "memory-export-only",
kind: "memory",
register(api) {
api.registerCli(() => {}, {
descriptors: [
{
name: "memory-export-only",
description: "Export-only memory CLI metadata",
hasSubcommands: true,
},
],
});
},
};`,
});
const registry = await loadOpenClawPluginCliRegistry({
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["memory-export-only"],
slots: { memory: "memory-other" },
},
},
});
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain(
"memory-export-only",
);
const memory = registry.plugins.find((entry) => entry.id === "memory-export-only");
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

@@ -1563,26 +1563,6 @@ export async function loadOpenClawPluginCliRegistry(
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;
@@ -1658,6 +1638,24 @@ export async function loadOpenClawPluginCliRegistry(
}
record.kind = definition?.kind ?? record.kind;
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
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 && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
if (typeof register !== "function") {
logger.error(`[plugins] ${record.id} missing register/activate export`);
pushPluginLoadError("plugin export missing register/activate");