fix(plugins): fail strict bootstrap on plugin load errors

This commit is contained in:
Vincent Koc 2026-03-19 16:04:19 -07:00
parent 009f494cd9
commit f3971571fe
4 changed files with 68 additions and 2 deletions

View File

@ -60,6 +60,7 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
throwOnLoadError: true,
}),
);
});
@ -85,11 +86,14 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ onlyPluginIds: [] }),
expect.objectContaining({ onlyPluginIds: [], throwOnLoadError: true }),
);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ onlyPluginIds: ["telegram", "slack"] }),
expect.objectContaining({
onlyPluginIds: ["telegram", "slack"],
throwOnLoadError: true,
}),
);
});
});

View File

@ -55,6 +55,7 @@ export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistrySco
config,
workspaceDir,
logger,
throwOnLoadError: true,
...(scope === "configured-channels"
? {
onlyPluginIds: resolveConfiguredChannelPluginIds({

View File

@ -1625,6 +1625,35 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
});
it("throws when strict plugin loading sees plugin errors", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "configurable",
filename: "configurable.cjs",
body: `module.exports = { id: "configurable", register() {} };`,
});
expect(() =>
loadOpenClawPlugins({
cache: false,
throwOnLoadError: true,
config: {
plugins: {
enabled: true,
load: { paths: [plugin.file] },
allow: ["configurable"],
entries: {
configurable: {
enabled: true,
config: "nope" as unknown as Record<string, unknown>,
},
},
},
},
}),
).toThrow("plugin load failed: configurable: invalid config: <root>: must be object");
});
it("fails when plugin export id mismatches manifest id", () => {
useNoBundledPlugins();
const plugin = writePlugin({

View File

@ -71,8 +71,25 @@ export type PluginLoadOptions = {
*/
preferSetupRuntimeForChannelPlugins?: boolean;
activate?: boolean;
throwOnLoadError?: boolean;
};
export class PluginLoadFailureError extends Error {
readonly pluginIds: string[];
readonly registry: PluginRegistry;
constructor(registry: PluginRegistry) {
const failedPlugins = registry.plugins.filter((entry) => entry.status === "error");
const summary = failedPlugins
.map((entry) => `${entry.id}: ${entry.error ?? "unknown plugin load error"}`)
.join("; ");
super(`plugin load failed: ${summary}`);
this.name = "PluginLoadFailureError";
this.pluginIds = failedPlugins.map((entry) => entry.id);
this.registry = registry;
}
}
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
const registryCache = new Map<string, PluginRegistry>();
const openAllowlistWarningCache = new Set<string>();
@ -413,6 +430,19 @@ function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnost
diagnostics.push(...append);
}
function maybeThrowOnPluginLoadError(
registry: PluginRegistry,
throwOnLoadError: boolean | undefined,
): void {
if (!throwOnLoadError) {
return;
}
if (!registry.plugins.some((entry) => entry.status === "error")) {
return;
}
throw new PluginLoadFailureError(registry);
}
type PathMatcher = {
exact: Set<string>;
dirs: string[];
@ -1253,6 +1283,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
env,
});
maybeThrowOnPluginLoadError(registry, options.throwOnLoadError);
if (cacheEnabled) {
setCachedPluginRegistry(cacheKey, registry);
}