From d4e61a86f0672aa782572d3fdaf582475c349633 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 31 Jul 2026 14:17:56 +0200 Subject: [PATCH] fix(ticket116): isoler crash plugin hello-plugin + erreur explicite UI --- crates/app-tauri/src/plugins.rs | 147 +++++++++++++++++- crates/application/src/plugin/mod.rs | 141 +++++++++++++---- .../PluginLayoutSelectorSection.test.tsx | 1 + .../plugins/PluginLayoutSelectorSection.tsx | 14 +- frontend/src/features/plugins/menus.test.ts | 35 ++++- frontend/src/features/plugins/menus.ts | 29 ++-- frontend/src/plugins/runtime/loader.test.ts | 33 ++++ frontend/src/plugins/runtime/loader.ts | 72 +++++++-- frontend/src/plugins/runtime/registry.ts | 9 +- 9 files changed, 417 insertions(+), 64 deletions(-) diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 69f74a0..c748655 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -302,7 +302,113 @@ fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> { #[cfg(test)] mod tests { - use super::block_on_protocol_future; + use super::{asset_allowed, block_on_protocol_future}; + use std::sync::Mutex; + + use async_trait::async_trait; + use domain::ports::{ + PluginManifestBytes, PluginManifestError, PluginPackageStore, PluginRegistryError, + PluginRegistryStore, PluginStoreError, + }; + use domain::{ + ContentHash, LocalPath, PluginId, PluginInstallSource, PluginLifecycleState, + PluginRegistry, PluginRegistryEntry, RelativePath, RemovalOutcome, StagedPluginPackage, + }; + use http::StatusCode; + + struct FakeRegistry { + registry: Mutex, + } + + #[async_trait] + impl PluginRegistryStore for FakeRegistry { + async fn load_registry(&self) -> Result { + Ok(self.registry.lock().unwrap().clone()) + } + + async fn save_registry( + &self, + registry: &PluginRegistry, + ) -> Result<(), PluginRegistryError> { + *self.registry.lock().unwrap() = registry.clone(); + Ok(()) + } + } + + struct FakePackages { + manifest: Vec, + } + + #[async_trait] + impl PluginPackageStore for FakePackages { + async fn list_installed(&self) -> Result, PluginStoreError> { + Ok(Vec::new()) + } + + async fn read_manifest( + &self, + _package: &domain::PluginPackageRef, + ) -> Result { + Ok(PluginManifestBytes { + bytes: self.manifest.clone(), + }) + } + + async fn install_from_archive( + &self, + _archive: &LocalPath, + ) -> Result { + Err(PluginStoreError::Invalid("not used".to_owned())) + } + + async fn install_from_directory( + &self, + _dir: &LocalPath, + ) -> Result { + Err(PluginStoreError::Invalid("not used".to_owned())) + } + + async fn commit_install( + &self, + _staged: StagedPluginPackage, + _plugin_id: &PluginId, + ) -> Result { + Err(PluginStoreError::Invalid("not used".to_owned())) + } + + async fn remove_package( + &self, + _plugin_id: &PluginId, + ) -> Result { + Ok(RemovalOutcome::NotFound) + } + + fn bundle_url( + &self, + plugin_id: &PluginId, + entry: &RelativePath, + hash: &ContentHash, + ) -> Result { + Ok(domain::PluginBundleUrl::new(format!( + "idea-plugin://{}/current/{}/{}", + plugin_id.as_str(), + hash.as_str(), + entry.as_str() + ))) + } + } + + struct RejectingValidator; + + impl domain::ports::PluginManifestValidator for RejectingValidator { + fn validate( + &self, + _bytes: &[u8], + _package: &domain::PluginPackageRef, + ) -> Result { + Err(PluginManifestError::Invalid("broken manifest".to_owned())) + } + } #[tokio::test(flavor = "multi_thread")] async fn protocol_future_can_be_waited_inside_tauri_runtime() { @@ -310,4 +416,43 @@ mod tests { assert_eq!(value, 42); } + + #[tokio::test] + async fn asset_allowed_confines_invalid_active_manifest_to_forbidden() { + let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap(); + let hash = ContentHash::new("abc123").unwrap(); + let registry = FakeRegistry { + registry: Mutex::new(PluginRegistry { + version: 1, + plugins: vec![PluginRegistryEntry { + id: plugin_id.clone(), + lifecycle_state: PluginLifecycleState::Enabled, + source: PluginInstallSource::Directory { + path_label: "/source/plugin".to_owned(), + }, + content_hash: hash.clone(), + restart_required: false, + error: None, + }], + }), + }; + let packages = FakePackages { + manifest: br#"{"broken":true}"#.to_vec(), + }; + let rel = RelativePath::new("dist/index.js").unwrap(); + + let err = asset_allowed( + &plugin_id, + hash.as_str(), + &rel, + ®istry, + &packages, + &RejectingValidator, + ) + .await + .unwrap_err(); + + assert_eq!(err.0, StatusCode::FORBIDDEN); + assert!(err.1.contains("broken manifest")); + } } diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index be29cdd..3b1c153 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -713,44 +713,99 @@ impl ListPluginRuntimeContributions { /// Executes the use case. pub async fn execute(&self) -> Result { - let registry = self.registry.load_registry().await.map_err(map_registry)?; + let mut registry = self.registry.load_registry().await.map_err(map_registry)?; let mut plugins = Vec::new(); - for entry in registry.plugins { + let mut invalid_plugins = Vec::new(); + for entry in registry.plugins.clone() { if !entry.lifecycle_state.is_runtime_active() { continue; } - let descriptor = - descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?; - let bundle = plugin_asset_url( - &descriptor.manifest.id, - descriptor.manifest.version.as_str(), - &descriptor.registry.content_hash, - &descriptor.manifest.main, - ); - let icon_url = match &descriptor.manifest.icon { - Some(icon) => Some(plugin_asset_url( - &descriptor.manifest.id, - descriptor.manifest.version.as_str(), - &descriptor.registry.content_hash, - icon, - )), - None => None, - }; - plugins.push(PluginRuntimePlugin { - id: descriptor.manifest.id.as_str().to_owned(), - display_name: descriptor.manifest.display_name, - publisher: descriptor.manifest.publisher, - version: descriptor.manifest.version.as_str().to_owned(), - bundle_url: bundle, - icon_url, - content_hash: descriptor.registry.content_hash.as_str().to_owned(), - contributes: descriptor.manifest.contributes, - }); + match runtime_plugin_from_entry( + self.packages.as_ref(), + self.validator.as_ref(), + entry.clone(), + ) + .await + { + Ok(plugin) => plugins.push(plugin), + Err(err) => { + let message = format!( + "runtime contributions disabled: plugin `{}` is not servable: {err}", + entry.id.as_str() + ); + crate::diag!("[plugins] {message}"); + invalid_plugins.push((entry.id, message)); + } + } + } + if !invalid_plugins.is_empty() { + for (plugin_id, message) in invalid_plugins { + if let Some(entry) = registry.plugins.iter_mut().find(|p| p.id == plugin_id) { + entry.lifecycle_state = PluginLifecycleState::Invalid; + entry.error = Some(message); + } + } + if let Err(err) = self + .registry + .save_registry(®istry) + .await + .map_err(map_registry) + { + crate::diag!("[plugins] failed to persist invalid runtime plugin state: {err}"); + } } Ok(PluginRuntimeCatalog { plugins }) } } +async fn runtime_plugin_from_entry( + packages: &dyn PluginPackageStore, + validator: &dyn PluginManifestValidator, + entry: PluginRegistryEntry, +) -> Result { + let descriptor = descriptor_for(packages, validator, entry).await?; + let bundle = checked_plugin_asset_url( + packages, + &descriptor.manifest.id, + descriptor.manifest.version.as_str(), + &descriptor.registry.content_hash, + &descriptor.manifest.main, + )?; + let icon_url = match &descriptor.manifest.icon { + Some(icon) => Some(checked_plugin_asset_url( + packages, + &descriptor.manifest.id, + descriptor.manifest.version.as_str(), + &descriptor.registry.content_hash, + icon, + )?), + None => None, + }; + Ok(PluginRuntimePlugin { + id: descriptor.manifest.id.as_str().to_owned(), + display_name: descriptor.manifest.display_name, + publisher: descriptor.manifest.publisher, + version: descriptor.manifest.version.as_str().to_owned(), + bundle_url: bundle, + icon_url, + content_hash: descriptor.registry.content_hash.as_str().to_owned(), + contributes: descriptor.manifest.contributes, + }) +} + +fn checked_plugin_asset_url( + packages: &dyn PluginPackageStore, + plugin_id: &PluginId, + version: &str, + hash: &ContentHash, + path: &domain::RelativePath, +) -> Result { + packages + .bundle_url(plugin_id, path, hash) + .map_err(map_store)?; + Ok(plugin_asset_url(plugin_id, version, hash, path)) +} + /// Reconciles plugin MCP servers. pub struct ReconcilePluginMcpServers { packages: Arc, @@ -1541,6 +1596,34 @@ mod tests { } } + #[tokio::test] + async fn runtime_catalog_marks_invalid_active_plugin_and_keeps_bootstrap_alive() { + let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let usecase = + ListPluginRuntimeContributions::new(packages, registry.clone(), Arc::new(validator())); + + let catalog = usecase.execute().await.unwrap(); + + assert!( + catalog.plugins.is_empty(), + "invalid active plugin must be excluded from runtime catalog" + ); + let saved = registry.load_registry().await.unwrap(); + let entry = saved.find(&plugin_id()).unwrap(); + assert_eq!(entry.lifecycle_state, PluginLifecycleState::Invalid); + assert!( + entry + .error + .as_deref() + .unwrap_or_default() + .contains("not servable"), + "registry must carry a confined runtime error: {entry:?}" + ); + } + #[tokio::test] async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx index e66be38..20cde5e 100644 --- a/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx @@ -75,6 +75,7 @@ describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => { menuItems: [], layouts: [ { type: "dev.acme.good", label: "Good layout", component: "X" }, + null, { type: "dev.acme.no-label", label: undefined, component: "X" }, { type: undefined, label: "No type", component: "X" }, ] as unknown as PluginContributionDto["layouts"], diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx index 7e364ee..7890cd5 100644 --- a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx @@ -30,6 +30,10 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +function objectOrNull(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + function finiteOrder(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } @@ -42,8 +46,10 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin return registry .layoutContributions() .flatMap(({ pluginId, pluginDisplayName, layout }) => { - const type = nonEmptyString(layout.type); - const label = nonEmptyString(layout.label); + const layoutObject = objectOrNull(layout); + if (!layoutObject) return []; + const type = nonEmptyString(layoutObject.type); + const label = nonEmptyString(layoutObject.label); if (!type || !label) return []; return [ { @@ -53,8 +59,8 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin ...layout, type, label, - order: finiteOrder(layout.order), - icon: nonEmptyString(layout.icon), + order: finiteOrder(layoutObject.order), + icon: nonEmptyString(layoutObject.icon), }, }, ]; diff --git a/frontend/src/features/plugins/menus.test.ts b/frontend/src/features/plugins/menus.test.ts index 1bc7fe7..55d539f 100644 --- a/frontend/src/features/plugins/menus.test.ts +++ b/frontend/src/features/plugins/menus.test.ts @@ -3,7 +3,7 @@ * acceptance criteria: "ordre déterministe, disabledReason, plugin disabled * absent, command handler appelé"). */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { PluginContributionDto } from "@/domain"; import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime"; @@ -19,11 +19,15 @@ const NO_CONTEXT: WhenContext = { }; function stubPlugin(pluginId: string, displayName: string, contributes: PluginContributionDto): LoadedPlugin { + const declaredCommandIds = contributes.menuItems.flatMap((item) => { + if (item === null || typeof item !== "object") return []; + return typeof item.command === "string" ? [item.command] : []; + }); return { pluginId, displayName, contributes, - commands: new PluginCommandRegistry(pluginId, new Set(contributes.menuItems.map((i) => i.command))), + commands: new PluginCommandRegistry(pluginId, new Set(declaredCommandIds)), layouts: new PluginLayoutRegistry(pluginId, new Set(contributes.layouts.map((l) => l.type))), menu: new PluginMenuRegistry(pluginId), dispose: async () => {}, @@ -67,6 +71,7 @@ describe("resolveTopLevelMenus", () => { ...empty(), menus: [ { id: "bad.menu", label: "Good", topLevel: true }, + null, { id: "bad.empty", label: "", topLevel: true }, { id: undefined, label: "No id", topLevel: true }, { id: "bad.no-label", label: undefined, topLevel: true }, @@ -167,6 +172,7 @@ describe("resolveMenuItems", () => { label: "Say Hello", command: "hello-plugin.sayHello", }, + null, { id: "hello-plugin.broken.item", targetMenuId: "hello-plugin.menu", @@ -199,4 +205,29 @@ describe("resolveMenuItems", () => { await registry.runCommand("dev.acme", "dev.acme.a.cmd"); expect(ran).toBe(true); }); + + it("confines a throwing command handler to the plugin command dispatch", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const registry = new PluginRuntimeRegistry(); + const plugin = stubPlugin("dev.acme", "Acme", { + ...empty(), + menuItems: [ + { id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" }, + ], + }); + plugin.commands.register("dev.acme.a.cmd", () => { + throw new Error("boom"); + }); + registry.add(plugin); + + await expect(registry.runCommand("dev.acme", "dev.acme.a.cmd")).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + '[plugin:dev.acme] command "dev.acme.a.cmd" failed', + expect.any(Error), + ); + } finally { + consoleError.mockRestore(); + } + }); }); diff --git a/frontend/src/features/plugins/menus.ts b/frontend/src/features/plugins/menus.ts index 61b5410..40745b6 100644 --- a/frontend/src/features/plugins/menus.ts +++ b/frontend/src/features/plugins/menus.ts @@ -22,6 +22,10 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +function objectOrNull(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + function finiteOrder(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } @@ -44,8 +48,10 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT return registry .topLevelMenus() .flatMap(({ pluginId, pluginDisplayName, menu }) => { - const id = nonEmptyString(menu.id); - const label = nonEmptyString(menu.label); + const menuObject = objectOrNull(menu); + if (!menuObject) return []; + const id = nonEmptyString(menuObject.id); + const label = nonEmptyString(menuObject.label); if (!id || !label) return []; return [ { @@ -53,8 +59,8 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT pluginId, pluginDisplayName, label, - icon: nonEmptyString(menu.icon), - order: finiteOrder(menu.order), + icon: nonEmptyString(menuObject.icon), + order: finiteOrder(menuObject.order), }, ]; }) @@ -80,13 +86,14 @@ export function resolveMenuItems( ): ResolvedPluginMenuItem[] { return registry .menuItems() - .filter(({ item }) => targetMatches(item.targetMenuId, targetMenuId)) .flatMap(({ pluginId, pluginDisplayName, item }) => { - const id = nonEmptyString(item.id); - const label = nonEmptyString(item.label); - const command = nonEmptyString(item.command); + const itemObject = objectOrNull(item); + if (!itemObject || !targetMatches(itemObject.targetMenuId, targetMenuId)) return []; + const id = nonEmptyString(itemObject.id); + const label = nonEmptyString(itemObject.label); + const command = nonEmptyString(itemObject.command); if (!id || !label || !command) return []; - const result = evaluateWhen(item.when, whenCtx); + const result = evaluateWhen(nonEmptyString(itemObject.when), whenCtx); return [ { id, @@ -98,8 +105,8 @@ export function resolveMenuItems( enabled: result.ok ? result.value : false, disabledReason: result.ok ? undefined : result.reason, groupLabel: nonEmptyString(pluginDisplayName) ?? pluginId, - order: finiteOrder(item.order), - iconUrl: nonEmptyString(item.icon), + order: finiteOrder(itemObject.order), + iconUrl: nonEmptyString(itemObject.icon), } satisfies ResolvedPluginMenuItem, ]; }) diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 9a221e3..77528a6 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -195,6 +195,39 @@ describe("loadPlugins", () => { expect((globalThis as Record).__helloArchiveCommandRan).toBe(true); }); + it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => { + const bundle = dataUrl(` + export function activate(ctx) { + globalThis.__healthyPluginActivated = ctx.pluginId; + } + `); + + const { registry, failures } = await loadPlugins( + [ + null, + entry({ + id: "dev.acme.healthy", + displayName: "Healthy", + bundleUrl: bundle, + contributes: { + menus: [null], + menuItems: [null, { id: "x", targetMenuId: "panels", label: "X" }], + layouts: [null, { label: "Missing type" }], + } as unknown as PluginContributionDto, + }), + ] as unknown as PluginRuntimePlugin[], + gateways, + ); + + expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.healthy"]); + expect(failures).toEqual([ + { pluginId: "", reason: expect.stringContaining("bundle URL") }, + ]); + expect((globalThis as Record).__healthyPluginActivated).toBe( + "dev.acme.healthy", + ); + }); + it("calls dispose() on removal (best-effort)", async () => { const bundle = dataUrl(` export function activate(ctx) { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 6c7a9ca..8329d4a 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -82,7 +82,7 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule { } function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger { - const prefix = `[plugin:${entry.id}]`; + const prefix = `[plugin:${safePluginId(entry)}]`; return { debug: (message, ...args) => console.debug(prefix, message, ...args), info: (message, ...args) => console.info(prefix, message, ...args), @@ -105,8 +105,38 @@ function arrayOrEmpty(value: unknown): T[] { return Array.isArray(value) ? (value as T[]) : []; } +function objectOrEmpty(value: unknown): Record { + return value !== null && typeof value === "object" ? (value as Record) : {}; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function safePluginId(entry: unknown): string { + return nonEmptyString(objectOrEmpty(entry).id) ?? ""; +} + +function commandIdsFromContributes(contributes: PluginContributionDto): Set { + return new Set( + contributes.menuItems.flatMap((item) => { + const command = objectOrEmpty(item).command; + return nonEmptyString(command) ? [command] : []; + }), + ); +} + +function layoutTypesFromContributes(contributes: PluginContributionDto): Set { + return new Set( + contributes.layouts.flatMap((layout) => { + const type = objectOrEmpty(layout).type; + return nonEmptyString(type) ? [type] : []; + }), + ); +} + function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto { - const contributes = entry.contributes as Partial | null | undefined; + const contributes = objectOrEmpty(entry.contributes) as Partial; return { menus: arrayOrEmpty(contributes?.menus), menuItems: arrayOrEmpty(contributes?.menuItems), @@ -135,32 +165,41 @@ async function loadOne( entry: PluginRuntimePlugin, gateways: PluginGatewaySet, ): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> { + const entryObject = objectOrEmpty(entry); + const pluginId = safePluginId(entry); + const displayName = nonEmptyString(entryObject.displayName) ?? pluginId; + const version = nonEmptyString(entryObject.version) ?? ""; try { // The bundle URL is a plugin-scoped, content-hashed local protocol URL // served by the backend (carnet §1.3) — never a disk path or arbitrary // remote URL, and the content hash busts the module cache after updates. - const mod: unknown = await import(/* @vite-ignore */ entry.bundleUrl); + const bundleUrl = nonEmptyString(entryObject.bundleUrl); + if (!bundleUrl) { + throw new Error("missing plugin bundle URL"); + } + + const mod: unknown = await import(/* @vite-ignore */ bundleUrl); if (!isIdeaPluginModule(mod)) { return { failure: { - pluginId: entry.id, + pluginId, reason: `bundle does not export an "activate(ctx)" function`, }, }; } const contributes = normalizeContributes(entry); - const declaredCommandIds = new Set(contributes.menuItems.map((item) => item.command)); - const declaredLayoutTypes = new Set(contributes.layouts.map((layout) => layout.type)); - const commands = new PluginCommandRegistry(entry.id, declaredCommandIds); - const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes); - const menu = new PluginMenuRegistry(entry.id); + const declaredCommandIds = commandIdsFromContributes(contributes); + const declaredLayoutTypes = layoutTypesFromContributes(contributes); + const commands = new PluginCommandRegistry(pluginId, declaredCommandIds); + const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes); + const menu = new PluginMenuRegistry(pluginId); const subscriptions: Disposable[] = []; const ctx: IdeaPluginContext = { - pluginId: entry.id, - pluginDisplayName: entry.displayName, - version: entry.version, + pluginId, + pluginDisplayName: displayName, + version, logger: createPluginLogger(entry), subscriptions, commands: createCommandContext(commands), @@ -172,8 +211,8 @@ async function loadOne( const activation = await mod.activate(ctx); const plugin: LoadedPlugin = { - pluginId: entry.id, - displayName: entry.displayName, + pluginId, + displayName, contributes, commands, layouts, @@ -188,7 +227,7 @@ async function loadOne( } catch (e) { return { failure: { - pluginId: entry.id, + pluginId, reason: e instanceof Error ? e.message : String(e), }, }; @@ -208,7 +247,8 @@ export async function loadPlugins( const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; - const results = await Promise.all(catalogPlugins.map((entry) => loadOne(entry, gateways))); + const entries = Array.isArray(catalogPlugins) ? catalogPlugins : []; + const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways))); for (const result of results) { if ("failure" in result) failures.push(result.failure); else registry.add(result.plugin); diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index a1afadf..212aff6 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -65,7 +65,14 @@ export class PluginCommandRegistry { async run(commandId: string, ...args: unknown[]): Promise { const handler = this.handlers.get(commandId); if (!handler) return; - await handler(...args); + try { + await handler(...args); + } catch (e) { + console.error( + `[plugin:${this.pluginId}] command "${commandId}" failed`, + e, + ); + } } has(commandId: string): boolean {