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