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

@ -146,9 +146,17 @@ fn spawn_detached(future: impl std::future::Future<Output = ()> + Send + 'static
}
struct AppPluginToolProvider {
install_from_directory: Arc<InstallPluginFromDirectory>,
reload: Arc<ReloadPlugin>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PluginInstallFromDirectoryToolArgs {
path: String,
expected_plugin_id: Option<String>,
}
#[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<Value, PluginToolError> {
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<PathBuf, PluginToolError> {
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<dyn PluginToolProvider> = 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(