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:
@ -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>>,
|
||||
|
||||
Reference in New Issue
Block a user