feat(plugins): tool MCP agent-facing idea_plugin_install_from_directory + reload couvrant le refresh UI — backend #279 (use case InstallPluginFromDirectory avec expectedPluginId, confinement répertoire sous project root: rejet absolu hors root/fichier/symlink escaping, soft refresh + événements plugin_installed/plugin_reloaded relayés, wiring app-tauri; QA verte ré-exécutée: application 2/2 directory_install, infrastructure mcp_server 39/39 dont routing plugin_install/plugin_reload, plugin_install_load 5/5)

This commit is contained in:
2026-09-08 13:08:48 +02:00
parent 391bff82ee
commit 979965aef3
6 changed files with 333 additions and 22 deletions

View File

@ -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<PluginInstallResult, AppError> {
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<String>,
) -> Result<PluginInstallResult, AppError> {
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<PluginInstallResult, AppError> {
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<HashMap<ProjectId, Project>>,