diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 4839e5531..d33f37184 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -642,3 +642,123 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", ( fetchSpy.mockRestore?.() }) }) + +describe("config-handler plugin loading error boundary (#1559)", () => { + test("returns empty defaults when loadAllPluginComponents throws", async () => { + //#given + ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() + spyOn(pluginLoader, "loadAllPluginComponents" as any).mockRejectedValue(new Error("crash")) + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then + expect(config.agent).toBeDefined() + }) + + test("returns empty defaults when loadAllPluginComponents times out", async () => { + //#given + ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() + spyOn(pluginLoader, "loadAllPluginComponents" as any).mockImplementation( + () => new Promise(() => {}) + ) + const pluginConfig: OhMyOpenCodeConfig = { + experimental: { plugin_load_timeout_ms: 100 }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then + expect(config.agent).toBeDefined() + }, 5000) + + test("logs error when loadAllPluginComponents fails", async () => { + //#given + ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() + spyOn(pluginLoader, "loadAllPluginComponents" as any).mockRejectedValue(new Error("crash")) + const logSpy = shared.log as ReturnType + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then + const logCalls = logSpy.mock.calls.map((c: unknown[]) => c[0]) + const hasPluginFailureLog = logCalls.some( + (msg: string) => typeof msg === "string" && msg.includes("Plugin loading failed") + ) + expect(hasPluginFailureLog).toBe(true) + }) + + test("passes through plugin data on successful load (identity test)", async () => { + //#given + ;(pluginLoader.loadAllPluginComponents as any).mockRestore?.() + spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({ + commands: { "test-cmd": { description: "test", template: "test" } }, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [], + plugins: [{ name: "test-plugin", version: "1.0.0" }], + errors: [], + }) + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then + const commands = config.command as Record + expect(commands["test-cmd"]).toBeDefined() + }) +}) diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 36191fafa..0b0eb9d3b 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -25,7 +25,7 @@ import { loadMcpConfigs } from "../features/claude-code-mcp-loader"; import { loadAllPluginComponents } from "../features/claude-code-plugin-loader"; import { createBuiltinMcps } from "../mcp"; import type { OhMyOpenCodeConfig } from "../config"; -import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline } from "../shared"; +import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline, addConfigLoadError } from "../shared"; import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir"; import { migrateAgentConfig } from "../shared/permission-compat"; import { AGENT_NAME_MAP } from "../shared/migration"; @@ -104,19 +104,40 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { } } - const pluginComponents = (pluginConfig.claude_code?.plugins ?? true) - ? await loadAllPluginComponents({ - enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, - }) - : { - commands: {}, - skills: {}, - agents: {}, - mcpServers: {}, - hooksConfigs: [], - plugins: [], - errors: [], - }; + const emptyPluginDefaults = { + commands: {}, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [] as { hooks?: Record }[], + plugins: [] as { name: string; version: string }[], + errors: [] as { pluginKey: string; installPath: string; error: string }[], + }; + + let pluginComponents: typeof emptyPluginDefaults; + const pluginsEnabled = pluginConfig.claude_code?.plugins ?? true; + + if (pluginsEnabled) { + const timeoutMs = pluginConfig.experimental?.plugin_load_timeout_ms ?? 10000; + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`Plugin loading timed out after ${timeoutMs}ms`)), timeoutMs) + ); + pluginComponents = await Promise.race([ + loadAllPluginComponents({ + enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + }), + timeoutPromise, + ]); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("[config-handler] Plugin loading failed", { error: errorMessage }); + addConfigLoadError({ path: "plugin-loading", error: errorMessage }); + pluginComponents = emptyPluginDefaults; + } + } else { + pluginComponents = emptyPluginDefaults; + } if (pluginComponents.plugins.length > 0) { log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {