diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 79ff3d72bce..d56d4ad99de 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3256,6 +3256,75 @@ module.exports = { expect(registry.channels).toHaveLength(expectedChannels); }); + it("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + + // Plugin whose setup-entry uses the bundled contract but loadSetupPlugin() throws + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-entry-throws-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-entry-throws-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-entry-throws-test"], + }, + null, + 2, + ), + "utf-8", + ); + // index.cjs: full entry (should NOT be reached if setup-entry is used) + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `module.exports = { id: "setup-entry-throws-test", register() {} };`, + "utf-8", + ); + // setup-entry.cjs: bundled contract whose loadSetupPlugin throws + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `module.exports = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin: () => { throw new Error("boom: setup plugin missing"); }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-throws-test"], + }, + }, + }); + + // The registry load should NOT crash; the error should be recorded as a + // per-plugin diagnostic rather than aborting the whole load. + expect(registry.diagnostics.length).toBeGreaterThanOrEqual(1); + const diagnostic = registry.diagnostics.find( + (d) => d.pluginId === "setup-entry-throws-test" && d.level === "error", + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic!.message).toContain("failed to load setup entry"); + }); + it("prefers setupEntry for configured channel loads during startup when opted in", () => { expect( __testing.shouldLoadChannelPluginInSetupRuntime({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6de0a4e9415..6e4be973f6d 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -646,6 +646,7 @@ function resolvePluginModuleExport(moduleExport: unknown): { function resolveSetupChannelRegistration(moduleExport: unknown): { plugin?: ChannelPlugin; + loadError?: unknown; } { const resolved = unwrapDefaultModuleExport(moduleExport); if (!resolved || typeof resolved !== "object") { @@ -659,11 +660,15 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { setupEntryRecord.kind === "bundled-channel-setup-entry" && typeof setupEntryRecord.loadSetupPlugin === "function" ) { - const loadedPlugin = setupEntryRecord.loadSetupPlugin(); - if (loadedPlugin && typeof loadedPlugin === "object") { - return { - plugin: loadedPlugin as ChannelPlugin, - }; + try { + const loadedPlugin = setupEntryRecord.loadSetupPlugin(); + if (loadedPlugin && typeof loadedPlugin === "object") { + return { + plugin: loadedPlugin as ChannelPlugin, + }; + } + } catch (err) { + return { loadError: err }; } } const setup = resolved as { @@ -1650,6 +1655,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi manifestRecord.setupSource ) { const setupRegistration = resolveSetupChannelRegistration(mod); + if (setupRegistration.loadError) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: setupRegistration.loadError, + logPrefix: `[plugins] ${record.id} failed to load setup entry from ${record.source}: `, + diagnosticMessagePrefix: "failed to load setup entry: ", + }); + continue; + } if (setupRegistration.plugin) { if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { pushPluginLoadError(