From 979965aef3ba2c7f64eb35e7a600e1489c3b29ab Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 8 Sep 2026 13:08:48 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugins):=20tool=20MCP=20agent-facing?= =?UTF-8?q?=20idea=5Fplugin=5Finstall=5Ffrom=5Fdirectory=20+=20reload=20co?= =?UTF-8?q?uvrant=20le=20refresh=20UI=20=E2=80=94=20backend=20#279=20(use?= =?UTF-8?q?=20case=20InstallPluginFromDirectory=20avec=20expectedPluginId,?= =?UTF-8?q?=20confinement=20r=C3=A9pertoire=20sous=20project=20root:=20rej?= =?UTF-8?q?et=20absolu=20hors=20root/fichier/symlink=20escaping,=20soft=20?= =?UTF-8?q?refresh=20+=20=C3=A9v=C3=A9nements=20plugin=5Finstalled/plugin?= =?UTF-8?q?=5Freloaded=20relay=C3=A9s,=20wiring=20app-tauri;=20QA=20verte?= =?UTF-8?q?=20r=C3=A9-ex=C3=A9cut=C3=A9e:=20application=202/2=20directory?= =?UTF-8?q?=5Finstall,=20infrastructure=20mcp=5Fserver=2039/39=20dont=20ro?= =?UTF-8?q?uting=20plugin=5Finstall/plugin=5Freload,=20plugin=5Finstall=5F?= =?UTF-8?q?load=205/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/application/src/plugin/mod.rs | 94 ++++++++++- crates/backend/src/lib.rs | 150 +++++++++++++++++- .../src/orchestrator/mcp/plugins.rs | 46 ++++-- .../src/orchestrator/mcp/tools.rs | 1 + crates/infrastructure/tests/mcp_server.rs | 61 ++++++- .../tests/plugin_install_load.rs | 3 +- 6 files changed, 333 insertions(+), 22 deletions(-) diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index a20de204..c4a9cb4f 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -2372,6 +2372,7 @@ impl InstallPluginFromArchive { self.validator.as_ref(), self.events.as_ref(), self.mcp.as_ref(), + None, self.packages .install_from_archive(&LocalPath::new(path)) .await @@ -2411,12 +2412,27 @@ impl InstallPluginFromDirectory { /// Executes the use case. pub async fn execute(&self, path: String) -> Result { + self.execute_with_expected_plugin_id(path, None).await + } + + /// Executes the use case and optionally requires the staged manifest to carry + /// a specific plugin id. The guard is evaluated before the package is committed. + pub async fn execute_with_expected_plugin_id( + &self, + path: String, + expected_plugin_id: Option, + ) -> Result { + let expected_plugin_id = expected_plugin_id + .map(PluginId::new) + .transpose() + .map_err(|e| AppError::Invalid(e.to_string()))?; install_from_staged( self.packages.as_ref(), self.registry.as_ref(), self.validator.as_ref(), self.events.as_ref(), self.mcp.as_ref(), + expected_plugin_id.as_ref(), self.packages .install_from_directory(&LocalPath::new(path)) .await @@ -2597,10 +2613,20 @@ async fn install_from_staged( validator: &dyn PluginManifestValidator, events: &dyn EventBus, mcp: &dyn PluginMcpSupervisor, + expected_plugin_id: Option<&PluginId>, staged: StagedPluginPackage, ) -> Result { let review = review_staged(packages, validator, &staged).await?; let plugin_id = review.manifest.id.clone(); + if let Some(expected) = expected_plugin_id { + if expected != &plugin_id { + return Err(AppError::Invalid(format!( + "plugin manifest id `{}` does not match expected plugin `{}`", + plugin_id.as_str(), + expected.as_str() + ))); + } + } crate::diag!( "[plugins] install staged reviewed plugin={} version={} hash={} source={}", plugin_id.as_str(), @@ -2620,7 +2646,7 @@ async fn install_from_staged( source: review.source.clone(), content_hash: ContentHash::new(review.content_hash.clone()) .map_err(|e| AppError::Invalid(e.to_string()))?, - restart_required: true, + restart_required: false, error: None, }; if let Err(err) = runtime_plugin_from_entry(packages, validator, entry.clone()).await { @@ -2674,7 +2700,7 @@ async fn install_from_staged( Ok(PluginInstallResult { plugin: admin, review, - restart_required: true, + restart_required: false, }) } @@ -3642,7 +3668,7 @@ mod tests { PluginStoreError, StoreError, }; use domain::remote::RemoteRef; - use domain::{BackgroundTaskState, ProjectPath}; + use domain::{BackgroundTaskState, PluginVersion, ProjectPath}; use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; @@ -4646,6 +4672,68 @@ mod tests { assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); } + #[tokio::test] + async fn directory_install_is_soft_refreshable_and_publishes_installed_event() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry::default()); + let events = Arc::new(FakeEvents::default()); + let install = InstallPluginFromDirectory::new( + packages, + registry.clone(), + Arc::new(validator()), + events.clone(), + Arc::new(FakeMcp::default()), + ); + + let result = install.execute("/source/plugin".to_owned()).await.unwrap(); + + assert!(!result.restart_required); + assert!(!result.plugin.restart_required); + assert!( + !registry + .load_registry() + .await + .unwrap() + .find(&plugin_id()) + .unwrap() + .restart_required + ); + assert_eq!( + events.events.lock().unwrap().as_slice(), + &[DomainEvent::PluginInstalled { + plugin_id: plugin_id(), + version: PluginVersion::new("1.2.3").unwrap(), + }] + ); + } + + #[tokio::test] + async fn directory_install_rejects_expected_plugin_id_mismatch_before_commit() { + let registry = Arc::new(FakeRegistry::default()); + let events = Arc::new(FakeEvents::default()); + let install = InstallPluginFromDirectory::new( + Arc::new(FakePackages::with_manifest(valid_manifest())), + registry.clone(), + Arc::new(validator()), + events.clone(), + Arc::new(FakeMcp::default()), + ); + + let error = install + .execute_with_expected_plugin_id( + "/source/plugin".to_owned(), + Some("dev.acme.other".to_owned()), + ) + .await + .unwrap_err(); + + assert!( + matches!(error, AppError::Invalid(message) if message.contains("does not match expected plugin")) + ); + assert!(registry.load_registry().await.unwrap().plugins.is_empty()); + assert!(events.events.lock().unwrap().is_empty()); + } + #[derive(Default)] struct FakeProjectStore { projects: Mutex>, diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 8c92c3ad..38ee3d2f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -146,9 +146,17 @@ fn spawn_detached(future: impl std::future::Future + Send + 'static } struct AppPluginToolProvider { + install_from_directory: Arc, reload: Arc, } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PluginInstallFromDirectoryToolArgs { + path: String, + expected_plugin_id: Option, +} + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct PluginReloadToolArgs { @@ -160,11 +168,32 @@ struct PluginReloadToolArgs { impl PluginToolProvider for AppPluginToolProvider { async fn handle_plugin_tool( &self, - _project: &Project, + project: &Project, _requester: &str, name: &str, arguments: Value, ) -> Result { + if name == "idea_plugin_install_from_directory" { + let args: PluginInstallFromDirectoryToolArgs = serde_json::from_value(arguments) + .map_err(|err| { + PluginToolError::new("INVALID", format!("invalid arguments: {err}")) + })?; + let path = resolve_confined_plugin_directory(project, &args.path)?; + let result = self + .install_from_directory + .execute_with_expected_plugin_id( + path.to_string_lossy().into_owned(), + args.expected_plugin_id, + ) + .await + .map_err(|err| PluginToolError::new(err.code(), err.to_string()))?; + return serde_json::to_value(result).map_err(|err| { + PluginToolError::new( + "INTERNAL", + format!("failed to serialize plugin install result: {err}"), + ) + }); + } if name != "idea_plugin_reload" { return Err(PluginToolError::new( "NOT_FOUND", @@ -190,9 +219,63 @@ impl PluginToolProvider for AppPluginToolProvider { } } +fn resolve_confined_plugin_directory( + project: &Project, + requested: &str, +) -> Result { + if requested.trim().is_empty() { + return Err(PluginToolError::new("INVALID", "path must not be empty")); + } + let root = Path::new(project.root.as_str()) + .canonicalize() + .map_err(|err| { + PluginToolError::new("INVALID", format!("project root cannot be resolved: {err}")) + })?; + let requested = Path::new(requested); + let candidate = if requested.is_absolute() { + requested.to_path_buf() + } else { + root.join(requested) + }; + let canonical = candidate.canonicalize().map_err(|err| { + PluginToolError::new( + "INVALID", + format!("plugin directory cannot be resolved: {err}"), + ) + })?; + if !canonical.starts_with(&root) { + return Err(PluginToolError::new( + "INVALID", + "plugin directory must remain within the requester project root", + )); + } + if !canonical.is_dir() { + return Err(PluginToolError::new( + "INVALID", + "plugin source must be a directory", + )); + } + Ok(canonical) +} + #[cfg(test)] mod backend_core_build_tests { - use super::BackendCore; + use std::path::Path; + + use super::{resolve_confined_plugin_directory, BackendCore}; + use domain::{Project, ProjectId, ProjectPath, RemoteRef}; + use uuid::Uuid; + + fn project(root: &Path) -> Project { + Project::new( + ProjectId::from_uuid(Uuid::new_v4()), + "plugin-tool-test", + ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(), + RemoteRef::local(), + 0, + ) + .unwrap() + } #[test] fn backend_core_builds_without_desktop_runtime() { @@ -204,6 +287,68 @@ mod backend_core_build_tests { let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn plugin_directory_resolution_accepts_relative_directory_under_project_root() { + let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4())); + let plugin = root.join("plugins/example"); + std::fs::create_dir_all(&plugin).unwrap(); + + let resolved = resolve_confined_plugin_directory(&project(&root), "plugins/example") + .expect("relative directory should resolve"); + + assert_eq!(resolved, plugin.canonicalize().unwrap()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn plugin_directory_resolution_rejects_absolute_path_outside_project_root() { + let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4())); + let outside = std::env::temp_dir().join(format!("idea-plugin-outside-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + + let error = + resolve_confined_plugin_directory(&project(&root), outside.to_string_lossy().as_ref()) + .unwrap_err(); + + assert_eq!(error.code, "INVALID"); + assert!(error.message.contains("within the requester project root")); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(outside); + } + + #[test] + fn plugin_directory_resolution_rejects_file_sources() { + let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("plugin.zip"), b"not a directory").unwrap(); + + let error = resolve_confined_plugin_directory(&project(&root), "plugin.zip").unwrap_err(); + + assert_eq!(error.code, "INVALID"); + assert!(error.message.contains("must be a directory")); + let _ = std::fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn plugin_directory_resolution_rejects_symlink_escaping_project_root() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!("idea-plugin-root-{}", Uuid::new_v4())); + let outside = std::env::temp_dir().join(format!("idea-plugin-outside-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + symlink(&outside, root.join("escape")).unwrap(); + + let error = resolve_confined_plugin_directory(&project(&root), "escape").unwrap_err(); + + assert_eq!(error.code, "INVALID"); + assert!(error.message.contains("within the requester project root")); + let _ = std::fs::remove_dir_all(root); + let _ = std::fs::remove_dir_all(outside); + } } #[derive(Clone, Default)] @@ -2769,6 +2914,7 @@ impl BackendCore { Arc::clone(&plugin_mcp_supervisor_port), )); let plugin_tool_provider: Arc = Arc::new(AppPluginToolProvider { + install_from_directory: Arc::clone(&install_plugin_from_directory), reload: Arc::clone(&reload_plugin), }); let set_plugin_enabled = Arc::new(SetPluginEnabled::new( diff --git a/crates/infrastructure/src/orchestrator/mcp/plugins.rs b/crates/infrastructure/src/orchestrator/mcp/plugins.rs index 7f84e6a5..74c24ede 100644 --- a/crates/infrastructure/src/orchestrator/mcp/plugins.rs +++ b/crates/infrastructure/src/orchestrator/mcp/plugins.rs @@ -49,23 +49,41 @@ pub trait PluginToolProvider: Send + Sync { /// Returns true when `name` is a public plugin tool. #[must_use] pub fn is_plugin_tool(name: &str) -> bool { - name == "idea_plugin_reload" + matches!( + name, + "idea_plugin_install_from_directory" | "idea_plugin_reload" + ) } /// Public plugin tool definitions advertised by `tools/list`. #[must_use] pub fn catalogue() -> Vec { - vec![ToolDef { - name: "idea_plugin_reload", - description: "Hot-reload an installed directory plugin from its recorded source.", - input_schema: json!({ - "type": "object", - "properties": { - "pluginId": { "type": "string" }, - "expectedContentHash": { "type": "string" } - }, - "required": ["pluginId"], - "additionalProperties": false - }), - }] + vec![ + ToolDef { + name: "idea_plugin_install_from_directory", + description: "Install a plugin from a directory confined to the requester project root; the emitted event lets the UI refresh plugin contributions without restarting IdeA.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "expectedPluginId": { "type": "string" } + }, + "required": ["path"], + "additionalProperties": false + }), + }, + ToolDef { + name: "idea_plugin_reload", + description: "Hot-reload an installed directory plugin from its recorded source; the emitted event lets the UI refresh plugin contributions.", + input_schema: json!({ + "type": "object", + "properties": { + "pluginId": { "type": "string" }, + "expectedContentHash": { "type": "string" } + }, + "required": ["pluginId"], + "additionalProperties": false + }), + }, + ] } diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 455dc4a0..63484070 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -96,6 +96,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_managed_temp_allocate", "idea_managed_temp_release", "idea_managed_temp_gc", + "idea_plugin_install_from_directory", "idea_plugin_reload", ]; diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 6a9a29f2..aeb02b8a 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -801,6 +801,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { "idea_template_update", "idea_template_delete", // Public plugin administration tools. + "idea_plugin_install_from_directory", "idea_plugin_reload", ] { assert!( @@ -811,8 +812,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 48, - "exactly the forty-eight exposed idea_* tools; got {names:?}" + 49, + "exactly the forty-nine exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema. @@ -925,6 +926,7 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() { assert!(!names.contains(&"idea_template_create")); assert!(!names.contains(&"idea_template_update")); assert!(!names.contains(&"idea_template_delete")); + assert!(!names.contains(&"idea_plugin_install_from_directory")); assert!(!names.contains(&"idea_plugin_reload")); } @@ -939,10 +941,12 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() { let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts); let ticket_tools = Arc::new(FakeTicketTools::default()); let template_tools = Arc::new(FakeTemplateTools::default()); + let plugin_tools = Arc::new(FakePluginTools::default()); let agent = AgentId::from_uuid(Uuid::from_u128(83)); let server = server_with_mcp_permissions(service, ProjectMcpToolPermissions::default()) .with_ticket_tools(ticket_tools.clone()) .with_template_tools(template_tools.clone()) + .with_plugin_tools(plugin_tools.clone()) .for_requester(agent.to_string()); for (id, tool, arguments) in [ @@ -1003,6 +1007,16 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() { "idea_template_delete", json!({ "templateId": Uuid::new_v4() }), ), + ( + 118, + "idea_plugin_install_from_directory", + json!({ "path": "plugins/example" }), + ), + ( + 119, + "idea_plugin_reload", + json!({ "pluginId": "dev.acme.gitgraph" }), + ), ] { let response = server .handle_raw(&tools_call(id, tool, arguments)) @@ -1033,6 +1047,10 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() { "denied template mutations must not reach the provider" ); assert_eq!(template_tools.mutation_attempts(), 0); + assert!( + plugin_tools.calls.lock().unwrap().is_empty(), + "denied plugin writes must not reach the provider" + ); } #[tokio::test] @@ -1072,6 +1090,45 @@ async fn explicitly_allowed_plugin_reload_routes_to_provider_with_camel_case_arg assert_eq!(calls[0].2["expectedContentHash"], json!("abc123")); } +#[tokio::test] +async fn explicitly_allowed_plugin_install_routes_to_provider_with_camel_case_arguments() { + let (service, _s) = build_service(FakeContexts::new()); + let plugin_tools = Arc::new(FakePluginTools::default()); + let agent = AgentId::from_uuid(Uuid::from_u128(279)); + let server = server_with_mcp_permissions( + service, + allow_doc(agent, &["idea_plugin_install_from_directory"]), + ) + .with_plugin_tools(plugin_tools.clone()) + .for_requester(agent.to_string()); + + let response = server + .handle_raw(&tools_call( + 279, + "idea_plugin_install_from_directory", + json!({ + "path": "plugins/example", + "expectedPluginId": "dev.acme.gitgraph" + }), + )) + .await + .expect("reply owed"); + + assert!( + response.error.is_none(), + "transport error: {:?}", + response.error + ); + let result = response.result.expect("tool result"); + assert_eq!(result["isError"], json!(false), "got {result}"); + let calls = plugin_tools.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, agent.to_string()); + assert_eq!(calls[0].1, "idea_plugin_install_from_directory"); + assert_eq!(calls[0].2["path"], json!("plugins/example")); + assert_eq!(calls[0].2["expectedPluginId"], json!("dev.acme.gitgraph")); +} + #[tokio::test] async fn legacy_mcp_requester_with_durable_store_is_read_only() { let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new()); diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs index 2d79a158..d8659781 100644 --- a/crates/infrastructure/tests/plugin_install_load.rs +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -107,7 +107,8 @@ async fn installs_reference_fixture_and_loads_runtime_catalog() { assert_eq!(result.plugin.display_name, "Reference Minimal Plugin"); assert_eq!(result.review.contribution_summary.top_level_menus, 1); assert_eq!(result.review.contribution_summary.menu_items, 1); - assert!(result.restart_required); + assert!(!result.restart_required); + assert!(!result.plugin.restart_required); assert!(app_data .join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json") .is_file()); From 44dc67722cb7a4bf0b36abf059e33e5bdded2174 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 8 Sep 2026 13:08:54 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(plugins):=20soft=20refresh=20UI=20des?= =?UTF-8?q?=20contributions=20plugins=20apr=C3=A8s=20install/reload=20?= =?UTF-8?q?=E2=80=94=20frontend=20#279=20(PluginRuntimeProvider=20refreshR?= =?UTF-8?q?untime=20sur=20=C3=A9v=C3=A9nements=20pluginInstalled/pluginRel?= =?UTF-8?q?oaded,=20dispose=20de=20l'ancien=20registry,=20catalogue=20list?= =?UTF-8?q?RuntimeContributions,=20gateway=20mock;=20QA=20verte=20r=C3=A9-?= =?UTF-8?q?ex=C3=A9cut=C3=A9e:=2035/35=20tests=20features/plugins=20dont?= =?UTF-8?q?=20nouveau=20PluginRuntimeProvider.test,=20typecheck=20OK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/adapters/mock/index.ts | 4 +- frontend/src/domain/index.ts | 8 + .../plugins/PluginRuntimeProvider.test.tsx | 182 ++++++++++++++++++ .../plugins/PluginRuntimeProvider.tsx | 131 ++++++------- .../src/features/plugins/plugins.test.tsx | 6 +- frontend/src/plugins/runtime/loader.ts | 8 +- 6 files changed, 259 insertions(+), 80 deletions(-) create mode 100644 frontend/src/features/plugins/PluginRuntimeProvider.test.tsx diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index e9fbb5c9..487627ae 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -4051,12 +4051,12 @@ export class MockPluginGateway implements PluginGateway { lifecycleState: "enabled", enabled: true, pendingUninstall: false, - restartRequired: true, + restartRequired: false, trustLevel: "full", contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`), }; this.plugins.push(plugin); - return { plugin: structuredClone(plugin), restartRequired: true }; + return { plugin: structuredClone(plugin), restartRequired: false }; } installFromArchive(path: string): Promise { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 2ce38738..0f702d3d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -282,6 +282,14 @@ export interface EmbeddedServerStatus { /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } + | { type: "pluginInstalled"; pluginId: string; version: string } + | { + type: "pluginReloaded"; + pluginId: string; + oldContentHash: string; + newContentHash: string; + changed: boolean; + } | { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentExited"; agentId: string; code: number } | { diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx new file mode 100644 index 00000000..0a6b7149 --- /dev/null +++ b/frontend/src/features/plugins/PluginRuntimeProvider.test.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createMockGateways, MockPluginGateway, MockSystemGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import type { + CustomPluginLayoutCell, + PluginRuntimeContributionCatalog, + PluginRuntimePlugin, +} from "@/domain"; +import { usePluginRuntime } from "./PluginRuntimeProvider"; +import { PluginLayoutCellView } from "./PluginLayoutCellView"; +import { PluginRuntimeProvider } from "./PluginRuntimeProvider"; + +function dataUrl(source: string): string { + return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; +} + +function runtimePlugin(overrides: Partial = {}): PluginRuntimePlugin { + return { + id: "dev.acme.refreshable", + displayName: "Refreshable v1", + version: "1.0.0", + bundleUrl: dataUrl("export function activate() {}"), + contentHash: "v1", + contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, + ...overrides, + }; +} + +class MutableRuntimeCatalogGateway extends MockPluginGateway { + calls = 0; + catalog: PluginRuntimeContributionCatalog = { plugins: [] }; + + async listRuntimeContributions(): Promise { + this.calls += 1; + return this.catalog; + } +} + +function RuntimeProbe({ onMount }: { onMount: () => void }) { + const { registry, loading } = usePluginRuntime(); + const [workspaceState, setWorkspaceState] = useState("initial"); + + useEffect(onMount, [onMount]); + + return ( +
+ + {loading ? "loading" : (registry.list()[0]?.displayName ?? "none")} + + {workspaceState} + +
+ ); +} + +describe("PluginRuntimeProvider soft refresh (#279)", () => { + it("reloads contributions after install and reload events without remounting its workspace", async () => { + const plugin = new MutableRuntimeCatalogGateway(); + const system = new MockSystemGateway(); + const gateways = { ...createMockGateways(), plugin, system }; + let mounts = 0; + const onMount = () => { + mounts += 1; + }; + + render( + + + + + , + ); + + expect(await screen.findByText("none")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "change workspace" })); + + plugin.catalog = { plugins: [runtimePlugin()] }; + system.emit({ + type: "pluginInstalled", + pluginId: "dev.acme.refreshable", + version: "1.0.0", + }); + expect(await screen.findByText("Refreshable v1")).toBeTruthy(); + + plugin.catalog = { + plugins: [ + runtimePlugin({ + displayName: "Refreshable v2", + version: "2.0.0", + contentHash: "v2", + bundleUrl: dataUrl("export function activate() { return { dispose() {} }; }"), + }), + ], + }; + system.emit({ + type: "pluginReloaded", + pluginId: "dev.acme.refreshable", + oldContentHash: "v1", + newContentHash: "v2", + changed: true, + }); + + expect(await screen.findByText("Refreshable v2")).toBeTruthy(); + expect(screen.getByTestId("workspace-state").textContent).toBe("preserved"); + expect(mounts).toBe(1); + expect(plugin.calls).toBe(3); + }); + + it("keeps a custom plugin layout cell mounted as a fallback when its contribution disappears", async () => { + const plugin = new MutableRuntimeCatalogGateway(); + const system = new MockSystemGateway(); + const gateways = { ...createMockGateways(), plugin, system }; + const testCell: CustomPluginLayoutCell = { + id: "plugin-cell-1", + pluginId: "dev.acme.refreshable", + layoutType: "dev.acme.refreshable.layout", + state: { tab: "history" }, + }; + plugin.catalog = { + plugins: [ + runtimePlugin({ + bundleUrl: dataUrl(` + export function activate(ctx) { + ctx.layouts.register({ + type: "dev.acme.refreshable.layout", + component: () => "Plugin layout" + }); + } + `), + contributes: { + menus: [], + menuItems: [], + layouts: [ + { + type: "dev.acme.refreshable.layout", + label: "Refreshable layout", + component: "RefreshableLayout" + } + ], + mcpServers: [], + }, + }), + ], + }; + + render( + + + {}} + onOpenPlugins={() => {}} + onChooseAnotherLayout={() => {}} + /> + + , + ); + + expect(await screen.findByText("Plugin layout")).toBeTruthy(); + plugin.catalog = { plugins: [] }; + system.emit({ + type: "pluginReloaded", + pluginId: testCell.pluginId, + oldContentHash: "v1", + newContentHash: "v2", + changed: true, + }); + + expect(await screen.findByText("Layout indisponible")).toBeTruthy(); + expect(testCell).toEqual({ + id: "plugin-cell-1", + pluginId: "dev.acme.refreshable", + layoutType: "dev.acme.refreshable.layout", + state: { tab: "history" }, + }); + await waitFor(() => expect(plugin.calls).toBe(2)); + }); +}); diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx index be35fc96..4ef66fbc 100644 --- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx +++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx @@ -1,14 +1,13 @@ /** - * `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session - * (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered + * `PluginRuntimeProvider` — owns the refreshable plugin runtime for one window. + * It fetches the runtime catalog (already filtered * to `enabled && !pendingUninstall` by the backend) and loads every bundle via * {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry} * to the rest of the tree (menus in F3, layouts in F4). * - * Mounted once near the app root, inside `` (it reads gateways via - * `useGateways()`). Never re-runs within a session — carnet §1.3: disable/ - * uninstall only mask contributions in-session; the actual reload happens on - * the next app start (a fresh mount of this provider). + * Mounted near the window root, inside `` (it reads gateways via + * `useGateways()`). Install/reload domain events rebuild only this context value: + * the workspace below stays mounted and retains all non-plugin UI/runtime state. */ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; @@ -73,6 +72,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti useEffect(() => { if (injected) return; let cancelled = false; + let refreshRevision = 0; const pluginGateways = { project: gateways.project, git: gateways.git, @@ -89,89 +89,78 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti pluginConfig: gateways.pluginConfig, pluginStorage: gateways.pluginStorage, }; - let activatedProjectScoped = false; let pendingProjectPlugins: PluginRuntimePlugin[] = []; let unsubscribeFocus: Unsubscribe | undefined; + let unsubscribeDomainEvents: Unsubscribe | undefined; + let activeRegistry = value.registry; - void gateways.focusedProject - .onFocusedProjectChanged(async (project) => { - if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return; - activatedProjectScoped = true; - const projectPlugins = pendingProjectPlugins; - pendingProjectPlugins = []; - try { - const result = await loadPlugins(projectPlugins, pluginGateways); - if (cancelled) return; - setValue((prev) => { - for (const plugin of result.registry.list()) prev.registry.add(plugin); - return { - registry: prev.registry, - failures: [...prev.failures, ...result.failures], - pending: prev.pending.filter( - (p) => !projectPlugins.some((entry) => entry.id === p.pluginId), - ), - loading: false, - }; - }); - } catch (e: unknown) { - if (cancelled) return; - setValue((prev) => ({ - ...prev, - failures: [ - ...prev.failures, - ...projectPlugins.map((plugin) => ({ - pluginId: plugin.id, - reason: describeError(e), - })), - ], - pending: prev.pending.filter( - (p) => !projectPlugins.some((entry) => entry.id === p.pluginId), - ), - loading: false, - })); - } - }) - .then((unsubscribe) => { - if (cancelled) unsubscribe(); - else unsubscribeFocus = unsubscribe; - }); + async function disposeRegistry(registry: PluginRuntimeRegistry): Promise { + await Promise.allSettled( + registry.list().map((plugin) => registry.remove(plugin.pluginId)), + ); + } - gateways.plugin - .listRuntimeContributions() - .then(async (catalog) => { + async function refreshRuntime(initial: boolean): Promise { + const revision = ++refreshRevision; + try { + const catalog = await gateways.plugin.listRuntimeContributions(); const result = await loadPlugins(catalog.plugins, pluginGateways); + if (cancelled || revision !== refreshRevision) { + await disposeRegistry(result.registry); + return; + } pendingProjectPlugins = catalog.plugins.filter((entry) => - result.pending.some((p) => p.pluginId === entry.id), + result.pending.some((pending) => pending.pluginId === entry.id), ); - return result; - }) - .then((result) => { - if (cancelled) return; + const previousRegistry = activeRegistry; + activeRegistry = result.registry; setValue({ registry: result.registry, failures: result.failures, pending: result.pending, loading: false, }); + if (previousRegistry !== result.registry) void disposeRegistry(previousRegistry); + } catch (e: unknown) { + if (cancelled || revision !== refreshRevision) return; + const failure = { pluginId: "", reason: describeError(e) }; + setValue((prev) => ({ + ...prev, + failures: initial ? [failure] : [...prev.failures, failure], + pending: initial ? [] : prev.pending, + loading: false, + })); + } + } + + void gateways.focusedProject + .onFocusedProjectChanged((project) => { + if (!project || pendingProjectPlugins.length === 0) return; + void refreshRuntime(false); }) - .catch((e: unknown) => { - // No plugin gateway / catalog fetch failed: run with zero plugins - // rather than blocking the app (full-trust plugins are additive). - if (!cancelled) { - setValue((prev) => ({ - ...prev, - failures: [ - ...prev.failures, - { pluginId: "", reason: describeError(e) }, - ], - pending: [], - loading: false, - })); - } + .then((unsubscribe) => { + if (cancelled) unsubscribe(); + else unsubscribeFocus = unsubscribe; }); + + void gateways.system + .onDomainEvent((event) => { + if (event.type === "pluginInstalled" || event.type === "pluginReloaded") { + void refreshRuntime(false); + } + }) + .then((unsubscribe) => { + if (cancelled) unsubscribe(); + else unsubscribeDomainEvents = unsubscribe; + }); + + void refreshRuntime(true); return () => { cancelled = true; + refreshRevision += 1; unsubscribeFocus?.(); + unsubscribeDomainEvents?.(); + void disposeRegistry(activeRegistry); }; // Gateways are a stable singleton for the app session (from `useGateways`); // re-running on every render would reload every plugin bundle. diff --git a/frontend/src/features/plugins/plugins.test.tsx b/frontend/src/features/plugins/plugins.test.tsx index 6b68c804..782f037b 100644 --- a/frontend/src/features/plugins/plugins.test.tsx +++ b/frontend/src/features/plugins/plugins.test.tsx @@ -1,7 +1,7 @@ /** * F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install * from archive/directory with pre-install review, full-trust mention visible, - * enable/disable/uninstall flows, and `restartRequired` rendered. + * enable/disable/uninstall flows, and lifecycle feedback rendered. */ import { describe, it, expect } from "vitest"; import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; @@ -327,9 +327,9 @@ describe("PluginsPanel", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Installer" })); - // The installed plugin now shows in the list, restart required (carnet §1.4). + // Install is live: the plugin is immediately active without a restart notice. expect(await screen.findByText("mock-plugin")).toBeTruthy(); - expect(screen.getByText("Redémarrage requis")).toBeTruthy(); + expect(screen.queryByText("Redémarrage requis")).toBeNull(); expect(screen.getByText("Activé")).toBeTruthy(); }); diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 50ec18f5..2914071f 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -1,7 +1,8 @@ /** * Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6). * - * At UI bootstrap, the app calls {@link loadPlugins} once with the catalog + * At UI bootstrap and after an install/reload event, the app calls + * {@link loadPlugins} with the catalog * from `PluginGateway.listRuntimeContributions()` (already filtered by the * backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set * used to build the public service facade. For each entry it dynamically imports @@ -370,9 +371,8 @@ function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): Plug /** * Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}. - * Called once at app bootstrap (and never again in the same session — carnet - * §1.3: disable/uninstall only masks contributions in-session, the actual - * unload happens on next restart via a fresh `loadPlugins` call). + * The provider swaps this fresh snapshot atomically at bootstrap and after a + * supported plugin lifecycle event. */ export async function loadPlugins( catalogPlugins: PluginRuntimePlugin[],