From bb35641715ae49d4f4c1535ac50a8a1cfa46f588 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 07:37:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20syst=C3=A8me=20de=20plugins=20?= =?UTF-8?q?=E2=80=94=20domaine,=20application,=20infrastructure=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lots B1-B4 : modèle de domaine des plugins (manifeste, capacités menus/layouts/MCP), port et registre applicatif, chargement/validation en infrastructure, exposition DTO et commandes Tauri. Tests cargo verts. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 + Cargo.toml | 1 + crates/app-tauri/Cargo.toml | 2 +- crates/app-tauri/src/lib.rs | 18 + crates/app-tauri/src/plugins.rs | 293 +++ crates/app-tauri/tests/dto_plugins.rs | 65 + crates/application/src/lib.rs | 8 + crates/application/src/plugin/mod.rs | 1714 +++++++++++++++++ .../application/tests/change_agent_profile.rs | 2 + crates/application/tests/reconcile_layouts.rs | 1 + .../tests/snapshot_running_agents.rs | 1 + crates/backend/src/dto.rs | 247 +++ crates/backend/src/events.rs | 93 + crates/backend/src/lib.rs | 151 +- crates/domain/src/events.rs | 48 + crates/domain/src/layout.rs | 26 + crates/domain/src/lib.rs | 31 +- crates/domain/src/plugin.rs | 681 +++++++ crates/domain/src/ports.rs | 165 ++ crates/domain/tests/layout.rs | 2 + crates/domain/tests/serde_roundtrip.rs | 34 +- crates/infrastructure/Cargo.toml | 3 + crates/infrastructure/src/lib.rs | 2 + crates/infrastructure/src/plugin/mod.rs | 755 ++++++++ 24 files changed, 4304 insertions(+), 41 deletions(-) create mode 100644 crates/app-tauri/src/plugins.rs create mode 100644 crates/app-tauri/tests/dto_plugins.rs create mode 100644 crates/application/src/plugin/mod.rs create mode 100644 crates/domain/src/plugin.rs create mode 100644 crates/infrastructure/src/plugin/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 126522b..92852ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1971,6 +1971,7 @@ dependencies = [ "fastembed", "futures-util", "git2", + "hex", "landlock", "notify", "portable-pty", @@ -1978,6 +1979,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "uuid", diff --git a/Cargo.toml b/Cargo.toml index a497c5e..1ff5cd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ hex = "0.4" sha2 = "0.10" subtle = "2" getrandom = "0.3" +http = "1" # Local git via libgit2. Network features (https/ssh → openssl) are off for L8: # only local operations (status/commit/branch/checkout/log) are in scope; remote # push/pull and static vendoring for the AppImage are deferred to L9/L11. diff --git a/crates/app-tauri/Cargo.toml b/crates/app-tauri/Cargo.toml index 7ed3c2d..0318b97 100644 --- a/crates/app-tauri/Cargo.toml +++ b/crates/app-tauri/Cargo.toml @@ -32,12 +32,12 @@ tauri-plugin-dialog = { workspace = true } tokio = { workspace = true, features = ["io-std", "rt", "net"] } serde = { workspace = true } serde_json = { workspace = true } +http = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } base64 = "0.22" bytes = "1.11" cookie = "0.18" -http = "1.4" http-body-util = "0.1" # `AppAgentResumer` implements the application's async `AgentResumer` port (LS7). async-trait = { workspace = true } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index f76c8cd..60f844a 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub mod events; pub mod mcp_bridge; pub mod mcp_endpoint; pub mod openai_tools; +pub mod plugins; pub mod pty; pub mod server; pub mod state; @@ -133,6 +134,9 @@ pub fn dispatch() -> ExitCode { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .register_uri_scheme_protocol("idea-plugin", |ctx, request| { + plugins::plugin_asset_protocol(ctx.app_handle(), request) + }) .setup(|app| { // Resolve the machine-local IDE data directory (ARCHITECTURE §9.2) // and build the composition root once the app handle exists, so the @@ -154,6 +158,7 @@ pub fn run() { events::spawn_relay(app.handle().clone(), &app_state.event_bus); let embedded_server = Arc::clone(&app_state.embedded_server); + let plugin_mcp_reconcile = Arc::clone(&app_state.reconcile_plugin_mcp_servers); let core = app_state.core(); app.manage(app_state); tauri::async_runtime::spawn(async move { @@ -165,6 +170,11 @@ pub fn run() { ); } }); + tauri::async_runtime::spawn(async move { + if let Err(err) = plugin_mcp_reconcile.execute().await { + application::diag!("[plugins] MCP reconcile at boot failed: {err}"); + } + }); // Kill all live PTYs cleanly when the main window is closing. This is // independent of the per-view (navigation/layout) lifecycle — those @@ -334,6 +344,14 @@ pub fn run() { commands::cancel_background_task, commands::retry_background_task, commands::list_background_tasks, + plugins::plugin_list_plugins, + plugins::plugin_review_package, + plugins::plugin_install_from_archive, + plugins::plugin_install_from_directory, + plugins::plugin_set_enabled, + plugins::plugin_uninstall, + plugins::plugin_list_runtime_contributions, + plugins::plugin_open_plugins_folder, commands::get_server_exposure_settings, commands::save_server_exposure_settings, commands::preview_server_exposure_settings, diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs new file mode 100644 index 0000000..f9a4c21 --- /dev/null +++ b/crates/app-tauri/src/plugins.rs @@ -0,0 +1,293 @@ +//! Plugin Tauri commands and asset protocol. + +use std::path::{Path, PathBuf}; + +use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput}; +use backend::dto::{ + ErrorDto, PluginAdminDto, PluginInstallResultDto, PluginReviewDto, + PluginRuntimeContributionCatalogDto, PluginUninstallResultDto, ReviewPluginPackageDto, +}; +use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; +use domain::{PluginId, RelativePath}; +use http::{header, Response, StatusCode}; +use tauri::{AppHandle, Manager, State}; + +use crate::state::AppState; + +/// Lists installed plugins for the admin surface. +#[tauri::command] +pub async fn plugin_list_plugins( + state: State<'_, AppState>, +) -> Result, ErrorDto> { + state + .list_plugins + .execute() + .await + .map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect()) + .map_err(ErrorDto::from) +} + +/// Reviews a local plugin package without committing it. +#[tauri::command] +pub async fn plugin_review_package( + input: ReviewPluginPackageDto, + state: State<'_, AppState>, +) -> Result { + let input = match input.source_kind.as_str() { + "archive" => ReviewPluginPackageInput::Archive { path: input.path }, + "directory" => ReviewPluginPackageInput::Directory { path: input.path }, + other => { + return Err(ErrorDto::invalid(format!( + "invalid plugin source kind: {other}" + ))) + } + }; + state + .review_plugin_package + .execute(input) + .await + .map(PluginReviewDto::from) + .map_err(ErrorDto::from) +} + +/// Installs a plugin from a local archive. +#[tauri::command] +pub async fn plugin_install_from_archive( + path: String, + state: State<'_, AppState>, +) -> Result { + state + .install_plugin_from_archive + .execute(path) + .await + .map(PluginInstallResultDto::from) + .map_err(ErrorDto::from) +} + +/// Installs a plugin from a local directory snapshot. +#[tauri::command] +pub async fn plugin_install_from_directory( + path: String, + state: State<'_, AppState>, +) -> Result { + state + .install_plugin_from_directory + .execute(path) + .await + .map(PluginInstallResultDto::from) + .map_err(ErrorDto::from) +} + +/// Enables or disables a plugin. +#[tauri::command] +pub async fn plugin_set_enabled( + plugin_id: String, + enabled: bool, + state: State<'_, AppState>, +) -> Result { + state + .set_plugin_enabled + .execute(SetPluginEnabledInput { plugin_id, enabled }) + .await + .map(PluginAdminDto::from) + .map_err(ErrorDto::from) +} + +/// Uninstalls a plugin. +#[tauri::command] +pub async fn plugin_uninstall( + plugin_id: String, + state: State<'_, AppState>, +) -> Result { + state + .uninstall_plugin + .execute(UninstallPluginInput { plugin_id }) + .await + .map(PluginUninstallResultDto::from) + .map_err(ErrorDto::from) +} + +/// Lists active runtime contributions for frontend bootstrap. +#[tauri::command] +pub async fn plugin_list_runtime_contributions( + state: State<'_, AppState>, +) -> Result { + state + .list_plugin_runtime_contributions + .execute() + .await + .map(PluginRuntimeContributionCatalogDto::from) + .map_err(ErrorDto::from) +} + +/// Opens the plugin store folder, or one plugin folder when an id is provided. +#[tauri::command] +pub fn plugin_open_plugins_folder( + plugin_id: Option, + app: AppHandle, +) -> Result<(), ErrorDto> { + let mut path = app + .path() + .app_data_dir() + .map_err(|e| ErrorDto::invalid(e.to_string()))? + .join("plugins"); + if let Some(id) = plugin_id { + let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?; + path = path.join("installed").join(id.as_str()); + } + open_folder(&path) +} + +/// Serves `idea-plugin://///` after +/// checking registry state, hash and root confinement. +pub fn plugin_asset_protocol( + app: &AppHandle, + request: http::Request>, +) -> Response> { + match plugin_asset_response(app, request) { + Ok(response) => response, + Err((status, message)) => Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(message.into_bytes()) + .expect("valid protocol error response"), + } +} + +fn plugin_asset_response( + app: &AppHandle, + request: http::Request>, +) -> Result>, (StatusCode, String)> { + let uri = request.uri(); + let plugin_id = uri + .host() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned())) + .and_then(|raw| { + PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string())) + })?; + let mut parts = uri.path().trim_start_matches('/').splitn(3, '/'); + let version_segment = parts.next().unwrap_or_default(); + let hash = parts.next().unwrap_or_default(); + let rel = parts.next().unwrap_or_default(); + if version_segment.is_empty() || hash.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "invalid plugin asset URL".to_owned(), + )); + } + let rel = + RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let state = app.state::(); + let allowed = tauri::async_runtime::block_on(asset_allowed( + &plugin_id, + hash, + &rel, + state.plugin_registry_store.as_ref(), + state.plugin_package_store.as_ref(), + state.plugin_manifest_validator.as_ref(), + ))?; + if !allowed { + return Err(( + StatusCode::FORBIDDEN, + "plugin asset is not active".to_owned(), + )); + } + let app_data = app + .path() + .app_data_dir() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let root = app_data + .join("plugins") + .join("installed") + .join(plugin_id.as_str()); + let target = root.join(rel.as_str()); + let root = root + .canonicalize() + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + let target = target + .canonicalize() + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + if !target.starts_with(&root) { + return Err(( + StatusCode::FORBIDDEN, + "asset escapes plugin root".to_owned(), + )); + } + let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type(&target)) + .body(bytes) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +async fn asset_allowed( + plugin_id: &PluginId, + hash: &str, + rel: &RelativePath, + registry_store: &dyn PluginRegistryStore, + package_store: &dyn PluginPackageStore, + validator: &dyn PluginManifestValidator, +) -> Result { + let registry = registry_store + .load_registry() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let Some(entry) = registry.find(plugin_id) else { + return Ok(false); + }; + if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash { + return Ok(false); + } + let package = domain::PluginPackageRef { + plugin_id: Some(plugin_id.clone()), + root: plugin_id.as_str().to_owned(), + }; + let manifest_bytes = package_store + .read_manifest(&package) + .await + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + let manifest = validator + .validate(&manifest_bytes.bytes, &package) + .map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?; + let declared_icon = manifest.icon.as_ref() == Some(rel); + let declared_main = manifest.main == *rel; + Ok(declared_main || declared_icon || rel.as_str().starts_with("assets/")) +} + +fn content_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + { + "js" | "mjs" => "text/javascript; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "css" => "text/css; charset=utf-8", + _ => "application/octet-stream", + } +} + +fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> { + let mut command = if cfg!(target_os = "macos") { + let mut c = std::process::Command::new("open"); + c.arg(path); + c + } else if cfg!(target_os = "windows") { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", ""]).arg(path); + c + } else { + let mut c = std::process::Command::new("xdg-open"); + c.arg(path); + c + }; + command + .spawn() + .map(|_| ()) + .map_err(|e| ErrorDto::invalid(e.to_string())) +} diff --git a/crates/app-tauri/tests/dto_plugins.rs b/crates/app-tauri/tests/dto_plugins.rs new file mode 100644 index 0000000..81e749e --- /dev/null +++ b/crates/app-tauri/tests/dto_plugins.rs @@ -0,0 +1,65 @@ +use app_tauri_lib::dto::{ + PluginAdminDto, PluginContributionSummaryDto, PluginRuntimeContributionCatalogDto, + PluginRuntimePluginDto, +}; +use domain::{PluginContributionSet, PluginLifecycleState, PluginTrustLevel}; + +#[test] +fn plugin_admin_dto_serialises_exact_contract_shape() { + let dto = PluginAdminDto { + id: "dev.acme.gitgraph".to_owned(), + display_name: "Git Graph".to_owned(), + publisher: Some("Acme".to_owned()), + version: "1.2.3".to_owned(), + description: Some("Graph".to_owned()), + icon_url: Some("idea-plugin://dev.acme.gitgraph/1.2.3/abc/assets/icon.svg".to_owned()), + source_kind: "archive".to_owned(), + source_label: Some("/tmp/gitgraph.ideaplug".to_owned()), + lifecycle_state: PluginLifecycleState::PendingDisable, + enabled: false, + pending_enable_state: Some(false), + pending_uninstall: false, + restart_required: true, + trust_level: PluginTrustLevel::Full, + contribution_summary: PluginContributionSummaryDto { + top_level_menus: 1, + menu_items: 2, + layouts: 3, + mcp_servers: 4, + }, + error: None, + }; + + let value = serde_json::to_value(dto).unwrap(); + assert_eq!(value["displayName"], "Git Graph"); + assert_eq!(value["lifecycleState"], "pending-disable"); + assert_eq!(value["trustLevel"], "full"); + assert_eq!(value["contributionSummary"]["topLevelMenus"], 1); +} + +#[test] +fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { + let dto = PluginRuntimeContributionCatalogDto { + plugins: vec![PluginRuntimePluginDto { + id: "dev.acme.gitgraph".to_owned(), + display_name: "Git Graph".to_owned(), + publisher: None, + version: "1.2.3".to_owned(), + bundle_url: "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js".to_owned(), + icon_url: None, + content_hash: "abc".to_owned(), + contributes: PluginContributionSet::default(), + }], + }; + + let value = serde_json::to_value(dto).unwrap(); + assert_eq!( + value["plugins"][0]["bundleUrl"], + "idea-plugin://dev.acme.gitgraph/1.2.3/abc/dist/index.js" + ); + assert_eq!(value["plugins"][0]["contentHash"], "abc"); + assert!(value["plugins"][0]["contributes"]["menus"] + .as_array() + .unwrap() + .is_empty()); +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 53c0eed..99a7623 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -27,6 +27,7 @@ pub mod memory; pub mod model_server; pub mod orchestrator; pub mod permission; +pub mod plugin; pub mod project; pub mod remote; pub mod skill; @@ -139,6 +140,13 @@ pub use permission::{ UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions, UpdateProjectPermissionsInput, }; +pub use plugin::{ + InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, + ListPluginRuntimeContributions, ListPlugins, PluginAdmin, PluginContributionSummary, + PluginInstallResult, PluginReview, PluginRuntimeCatalog, PluginRuntimePlugin, + ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, + SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult, +}; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs new file mode 100644 index 0000000..be29cdd --- /dev/null +++ b/crates/application/src/plugin/mod.rs @@ -0,0 +1,1714 @@ +//! Plugin application use cases. + +use std::collections::HashSet; +use std::sync::Arc; + +use domain::ports::{ + EventBus, LocalPath, PluginManifestBytes, PluginManifestError, PluginManifestValidator, + PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, + PluginRegistryStore, PluginStoreError, +}; +use domain::{ + ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, + PluginInstallSource, PluginLifecycleState, PluginManifest, PluginMcpServerSpec, + PluginRegistryEntry, PluginTrustLevel, RemovalOutcome, StagedPluginPackage, +}; +use serde::{Deserialize, Serialize}; + +use crate::AppError; + +/// Contribution counts for admin display. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginContributionSummary { + /// Top-level menu count. + pub top_level_menus: usize, + /// Menu item count. + pub menu_items: usize, + /// Layout count. + pub layouts: usize, + /// MCP server count. + pub mcp_servers: usize, +} + +impl From<&PluginContributionSet> for PluginContributionSummary { + fn from(c: &PluginContributionSet) -> Self { + Self { + top_level_menus: c.menus.len(), + menu_items: c.menu_items.len(), + layouts: c.layouts.len(), + mcp_servers: c.mcp_servers.len(), + } + } +} + +/// Admin plugin view. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginAdmin { + /// Plugin id. + pub id: String, + /// Display name. + pub display_name: String, + /// Publisher. + pub publisher: Option, + /// Version. + pub version: String, + /// Description. + pub description: Option, + /// Icon URL. + pub icon_url: Option, + /// Source kind. + pub source_kind: String, + /// Source label. + pub source_label: Option, + /// Lifecycle state. + pub lifecycle_state: PluginLifecycleState, + /// Enabled projection. + pub enabled: bool, + /// Pending enable state. + pub pending_enable_state: Option, + /// Pending uninstall flag. + pub pending_uninstall: bool, + /// Restart required flag. + pub restart_required: bool, + /// Trust level. + pub trust_level: PluginTrustLevel, + /// Summary. + pub contribution_summary: PluginContributionSummary, + /// Error. + pub error: Option, +} + +/// Pre-install package review. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginReview { + /// Manifest. + pub manifest: PluginManifest, + /// Source. + pub source: PluginInstallSource, + /// Content hash. + pub content_hash: String, + /// Summary. + pub contribution_summary: PluginContributionSummary, + /// Full-trust marker. + pub trust_level: PluginTrustLevel, +} + +/// Install result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstallResult { + /// Installed plugin. + pub plugin: PluginAdmin, + /// Review used for installation. + pub review: PluginReview, + /// Restart required flag. + pub restart_required: bool, +} + +/// Uninstall result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UninstallPluginResult { + /// Plugin id. + pub plugin_id: String, + /// Removal outcome. + pub removal_outcome: RemovalOutcome, + /// Restart required flag. + pub restart_required: bool, +} + +/// Runtime catalog. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimeCatalog { + /// Runtime plugins. + pub plugins: Vec, +} + +/// Runtime plugin entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimePlugin { + /// Plugin id. + pub id: String, + /// Display name. + pub display_name: String, + /// Publisher. + pub publisher: Option, + /// Version. + pub version: String, + /// Bundle URL. + pub bundle_url: String, + /// Icon URL. + pub icon_url: Option, + /// Content hash. + pub content_hash: String, + /// Contributions. + pub contributes: PluginContributionSet, +} + +/// Input for reviewing a package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReviewPluginPackageInput { + /// Review local archive. + Archive { + /// Path. + path: String, + }, + /// Review local directory. + Directory { + /// Path. + path: String, + }, +} + +fn map_store(e: PluginStoreError) -> AppError { + match e { + PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()), + PluginStoreError::Invalid(m) | PluginStoreError::Format(m) => AppError::Invalid(m), + PluginStoreError::Io(m) => AppError::FileSystem(m), + } +} + +fn map_registry(e: PluginRegistryError) -> AppError { + match e { + PluginRegistryError::Io(m) => AppError::Store(m), + PluginRegistryError::Serialization(m) => AppError::Store(m), + } +} + +fn map_manifest(e: PluginManifestError) -> AppError { + match e { + PluginManifestError::Json(m) + | PluginManifestError::Invalid(m) + | PluginManifestError::IncompatibleEngine(m) => AppError::Invalid(m), + } +} + +fn map_mcp(e: PluginMcpError) -> AppError { + AppError::Process(e.to_string()) +} + +fn plugin_package_ref(id: &PluginId) -> domain::PluginPackageRef { + domain::PluginPackageRef { + plugin_id: Some(id.clone()), + root: id.as_str().to_owned(), + } +} + +fn admin_from_descriptor( + d: PluginDescriptor, + _packages: &dyn PluginPackageStore, +) -> Result { + let icon_url = match &d.manifest.icon { + Some(icon) => Some(plugin_asset_url( + &d.manifest.id, + d.manifest.version.as_str(), + &d.registry.content_hash, + icon, + )), + None => None, + }; + Ok(PluginAdmin { + id: d.manifest.id.as_str().to_owned(), + display_name: d.manifest.display_name, + publisher: d.manifest.publisher, + version: d.manifest.version.as_str().to_owned(), + description: d.manifest.description, + icon_url, + source_kind: d.registry.source.kind().to_owned(), + source_label: Some(d.registry.source.label().to_owned()), + lifecycle_state: d.registry.lifecycle_state, + enabled: matches!( + d.registry.lifecycle_state, + PluginLifecycleState::Enabled | PluginLifecycleState::PendingEnable + ), + pending_enable_state: match d.registry.lifecycle_state { + PluginLifecycleState::PendingEnable => Some(true), + PluginLifecycleState::PendingDisable => Some(false), + _ => None, + }, + pending_uninstall: d.registry.lifecycle_state == PluginLifecycleState::PendingUninstall, + restart_required: d.registry.restart_required, + trust_level: d.manifest.trust_level, + contribution_summary: PluginContributionSummary::from(&d.manifest.contributes), + error: d.registry.error, + }) +} + +fn plugin_asset_url( + plugin_id: &PluginId, + version: &str, + hash: &ContentHash, + path: &domain::RelativePath, +) -> String { + format!( + "idea-plugin://{}/{}/{}/{}", + plugin_id.as_str(), + version, + hash.as_str(), + path.as_str() + ) +} + +async fn descriptor_for( + packages: &dyn PluginPackageStore, + validator: &dyn PluginManifestValidator, + entry: PluginRegistryEntry, +) -> Result { + let bytes = packages + .read_manifest(&plugin_package_ref(&entry.id)) + .await + .map_err(map_store)?; + let manifest = validator + .validate(&bytes.bytes, &plugin_package_ref(&entry.id)) + .map_err(map_manifest)?; + Ok(PluginDescriptor { + manifest, + registry: entry, + }) +} + +/// Lists admin plugins. +pub struct ListPlugins { + packages: Arc, + registry: Arc, + validator: Arc, +} + +impl ListPlugins { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + } + } + + /// Executes the use case. + pub async fn execute(&self) -> Result, AppError> { + let registry = self.registry.load_registry().await.map_err(map_registry)?; + let mut out = Vec::new(); + for entry in registry.plugins { + match descriptor_for( + self.packages.as_ref(), + self.validator.as_ref(), + entry.clone(), + ) + .await + { + Ok(d) => out.push(admin_from_descriptor(d, self.packages.as_ref())?), + Err(err) => { + let invalid = PluginRegistryEntry { + lifecycle_state: PluginLifecycleState::Invalid, + error: Some(err.to_string()), + ..entry + }; + let placeholder = PluginManifest { + idea_plugin_manifest_version: 1, + id: invalid.id.clone(), + display_name: invalid.id.as_str().to_owned(), + publisher: None, + version: domain::PluginVersion::new("0.0.0") + .expect("literal semver is valid"), + description: None, + engine_idea: None, + main: domain::RelativePath::new("dist/index.js") + .expect("literal path is valid"), + icon: None, + trust_level: PluginTrustLevel::Full, + capabilities: Vec::new(), + contributes: PluginContributionSet::default(), + }; + out.push(admin_from_descriptor( + PluginDescriptor { + manifest: placeholder, + registry: invalid, + }, + self.packages.as_ref(), + )?); + } + } + } + out.sort_by(|a, b| a.display_name.cmp(&b.display_name).then(a.id.cmp(&b.id))); + Ok(out) + } +} + +/// Reviews a plugin package without committing it. +pub struct ReviewPluginPackage { + packages: Arc, + validator: Arc, +} + +impl ReviewPluginPackage { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + validator: Arc, + ) -> Self { + Self { + packages, + validator, + } + } + + /// Executes the use case. + pub async fn execute(&self, input: ReviewPluginPackageInput) -> Result { + let staged = match input { + ReviewPluginPackageInput::Archive { path } => self + .packages + .install_from_archive(&LocalPath::new(path)) + .await + .map_err(map_store)?, + ReviewPluginPackageInput::Directory { path } => self + .packages + .install_from_directory(&LocalPath::new(path)) + .await + .map_err(map_store)?, + }; + review_staged(self.packages.as_ref(), self.validator.as_ref(), &staged).await + } +} + +async fn review_staged( + packages: &dyn PluginPackageStore, + validator: &dyn PluginManifestValidator, + staged: &StagedPluginPackage, +) -> Result { + let package = domain::PluginPackageRef { + plugin_id: None, + root: staged.root.clone(), + }; + let PluginManifestBytes { bytes } = + packages.read_manifest(&package).await.map_err(map_store)?; + let manifest = validator.validate(&bytes, &package).map_err(map_manifest)?; + Ok(PluginReview { + contribution_summary: PluginContributionSummary::from(&manifest.contributes), + trust_level: manifest.trust_level, + manifest, + source: staged.source.clone(), + content_hash: staged.content_hash.as_str().to_owned(), + }) +} + +/// Installs from archive. +pub struct InstallPluginFromArchive { + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, +} + +impl InstallPluginFromArchive { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + events, + mcp, + } + } + + /// Executes the use case. + pub async fn execute(&self, path: String) -> Result { + install_from_staged( + self.packages.as_ref(), + self.registry.as_ref(), + self.validator.as_ref(), + self.events.as_ref(), + self.mcp.as_ref(), + self.packages + .install_from_archive(&LocalPath::new(path)) + .await + .map_err(map_store)?, + ) + .await + } +} + +/// Installs from directory. +pub struct InstallPluginFromDirectory { + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, +} + +impl InstallPluginFromDirectory { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + events, + mcp, + } + } + + /// Executes the use case. + pub async fn execute(&self, path: String) -> Result { + install_from_staged( + self.packages.as_ref(), + self.registry.as_ref(), + self.validator.as_ref(), + self.events.as_ref(), + self.mcp.as_ref(), + self.packages + .install_from_directory(&LocalPath::new(path)) + .await + .map_err(map_store)?, + ) + .await + } +} + +async fn install_from_staged( + packages: &dyn PluginPackageStore, + registry_store: &dyn PluginRegistryStore, + validator: &dyn PluginManifestValidator, + events: &dyn EventBus, + mcp: &dyn PluginMcpSupervisor, + staged: StagedPluginPackage, +) -> Result { + let review = review_staged(packages, validator, &staged).await?; + let plugin_id = review.manifest.id.clone(); + packages + .commit_install(staged, &plugin_id) + .await + .map_err(map_store)?; + let mut registry = registry_store.load_registry().await.map_err(map_registry)?; + let entry = PluginRegistryEntry { + id: plugin_id.clone(), + lifecycle_state: PluginLifecycleState::Enabled, + source: review.source.clone(), + content_hash: ContentHash::new(review.content_hash.clone()) + .map_err(|e| AppError::Invalid(e.to_string()))?, + restart_required: true, + error: None, + }; + registry.upsert(entry.clone()); + registry_store + .save_registry(®istry) + .await + .map_err(map_registry)?; + events.publish(DomainEvent::PluginInstalled { + plugin_id: plugin_id.clone(), + version: review.manifest.version.clone(), + }); + let _ = mcp + .reconcile(active_mcp_specs(packages, validator, ®istry).await?) + .await; + let admin = admin_from_descriptor( + PluginDescriptor { + manifest: review.manifest.clone(), + registry: entry, + }, + packages, + )?; + Ok(PluginInstallResult { + plugin: admin, + review, + restart_required: true, + }) +} + +/// Enable/disable input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetPluginEnabledInput { + /// Plugin id. + pub plugin_id: String, + /// Desired enabled state. + pub enabled: bool, +} + +/// Enables or disables a plugin. +pub struct SetPluginEnabled { + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, +} + +impl SetPluginEnabled { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + events, + mcp, + } + } + + /// Executes the use case. + pub async fn execute(&self, input: SetPluginEnabledInput) -> Result { + let plugin_id = + PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?; + let mut registry = self.registry.load_registry().await.map_err(map_registry)?; + let entry = registry + .plugins + .iter_mut() + .find(|p| p.id == plugin_id) + .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?; + entry.lifecycle_state = if input.enabled { + PluginLifecycleState::Enabled + } else { + PluginLifecycleState::Disabled + }; + entry.restart_required = true; + let saved = entry.clone(); + self.registry + .save_registry(®istry) + .await + .map_err(map_registry)?; + if input.enabled { + self.events.publish(DomainEvent::PluginEnabled { + plugin_id: plugin_id.clone(), + }); + } else { + self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?; + self.events.publish(DomainEvent::PluginDisabled { + plugin_id: plugin_id.clone(), + restart_required: true, + }); + } + let _ = self + .mcp + .reconcile( + active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry) + .await?, + ) + .await; + let descriptor = + descriptor_for(self.packages.as_ref(), self.validator.as_ref(), saved).await?; + admin_from_descriptor(descriptor, self.packages.as_ref()) + } +} + +/// Uninstall input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UninstallPluginInput { + /// Plugin id. + pub plugin_id: String, +} + +/// Uninstalls a plugin. +pub struct UninstallPlugin { + packages: Arc, + registry: Arc, + events: Arc, + mcp: Arc, +} + +impl UninstallPlugin { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + events: Arc, + mcp: Arc, + ) -> Self { + Self { + packages, + registry, + events, + mcp, + } + } + + /// Executes the use case. + pub async fn execute( + &self, + input: UninstallPluginInput, + ) -> Result { + let plugin_id = + PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?; + self.mcp.stop_plugin(&plugin_id).await.map_err(map_mcp)?; + let mut registry = self.registry.load_registry().await.map_err(map_registry)?; + registry + .remove(&plugin_id) + .ok_or_else(|| AppError::NotFound("plugin".to_owned()))?; + self.registry + .save_registry(®istry) + .await + .map_err(map_registry)?; + let removal = self + .packages + .remove_package(&plugin_id) + .await + .map_err(map_store)?; + self.events.publish(DomainEvent::PluginUninstalled { + plugin_id: plugin_id.clone(), + restart_required: true, + }); + Ok(UninstallPluginResult { + plugin_id: plugin_id.as_str().to_owned(), + removal_outcome: removal, + restart_required: true, + }) + } +} + +/// Lists runtime contributions. +pub struct ListPluginRuntimeContributions { + packages: Arc, + registry: Arc, + validator: Arc, +} + +impl ListPluginRuntimeContributions { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + } + } + + /// Executes the use case. + pub async fn execute(&self) -> Result { + let registry = self.registry.load_registry().await.map_err(map_registry)?; + let mut plugins = Vec::new(); + for entry in registry.plugins { + if !entry.lifecycle_state.is_runtime_active() { + continue; + } + let descriptor = + descriptor_for(self.packages.as_ref(), self.validator.as_ref(), entry).await?; + let bundle = plugin_asset_url( + &descriptor.manifest.id, + descriptor.manifest.version.as_str(), + &descriptor.registry.content_hash, + &descriptor.manifest.main, + ); + let icon_url = match &descriptor.manifest.icon { + Some(icon) => Some(plugin_asset_url( + &descriptor.manifest.id, + descriptor.manifest.version.as_str(), + &descriptor.registry.content_hash, + icon, + )), + None => None, + }; + plugins.push(PluginRuntimePlugin { + id: descriptor.manifest.id.as_str().to_owned(), + display_name: descriptor.manifest.display_name, + publisher: descriptor.manifest.publisher, + version: descriptor.manifest.version.as_str().to_owned(), + bundle_url: bundle, + icon_url, + content_hash: descriptor.registry.content_hash.as_str().to_owned(), + contributes: descriptor.manifest.contributes, + }); + } + Ok(PluginRuntimeCatalog { plugins }) + } +} + +/// Reconciles plugin MCP servers. +pub struct ReconcilePluginMcpServers { + packages: Arc, + registry: Arc, + validator: Arc, + mcp: Arc, +} + +impl ReconcilePluginMcpServers { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + mcp: Arc, + ) -> Self { + Self { + packages, + registry, + validator, + mcp, + } + } + + /// Executes the use case. + pub async fn execute(&self) -> Result { + let registry = self.registry.load_registry().await.map_err(map_registry)?; + let specs = + active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?; + self.mcp.reconcile(specs).await.map_err(map_mcp) + } +} + +async fn active_mcp_specs( + packages: &dyn PluginPackageStore, + validator: &dyn PluginManifestValidator, + registry: &domain::PluginRegistry, +) -> Result, AppError> { + let installed_roots = packages + .list_installed() + .await + .map_err(map_store)? + .into_iter() + .filter_map(|p| p.plugin_id.clone().map(|id| (id, p.root))) + .collect::>(); + let app_data_dir = packages.app_data_dir_label(); + let mut specs = Vec::new(); + for entry in ®istry.plugins { + if !entry.lifecycle_state.is_runtime_active() { + continue; + } + let descriptor = descriptor_for(packages, validator, entry.clone()).await?; + let plugin_root = installed_roots + .get(&descriptor.manifest.id) + .cloned() + .unwrap_or_else(|| plugin_package_ref(&descriptor.manifest.id).root); + for server in descriptor.manifest.contributes.mcp_servers { + if !server.auto_start { + continue; + } + let command = substitute_vars(&server.command, &plugin_root, app_data_dir.as_deref()); + let command = if server.allow_absolute_command || looks_absolute(&command) { + command + } else { + format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command) + }; + specs.push(PluginMcpServerSpec { + identity: format!( + "plugin:{}:{}", + descriptor.manifest.id.as_str(), + server.id.as_str() + ), + plugin_id: descriptor.manifest.id.clone(), + server_id: server.id, + display_name: server.display_name, + command, + args: server + .args + .into_iter() + .map(|a| substitute_vars(&a, &plugin_root, app_data_dir.as_deref())) + .collect(), + env: server + .env + .into_iter() + .map(|(k, v)| { + ( + k, + substitute_vars(&v, &plugin_root, app_data_dir.as_deref()), + ) + }) + .collect(), + cwd: substitute_vars( + server.cwd.as_deref().unwrap_or("${pluginRoot}"), + &plugin_root, + app_data_dir.as_deref(), + ), + transport: server.transport, + }); + } + } + Ok(specs) +} + +fn substitute_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String { + let value = raw.replace("${pluginRoot}", plugin_root); + match app_data_dir { + Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir), + None => value, + } +} + +/// JSON manifest validator. +#[derive(Debug, Clone)] +pub struct JsonPluginManifestValidator { + idea_version: String, +} + +impl JsonPluginManifestValidator { + /// Builds a validator for the current app version. + #[must_use] + pub fn new(idea_version: impl Into) -> Self { + Self { + idea_version: idea_version.into(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawManifest { + idea_plugin_manifest_version: u32, + id: String, + display_name: String, + #[serde(default)] + publisher: Option, + version: String, + #[serde(default)] + description: Option, + #[serde(default)] + engines: RawEngines, + main: String, + #[serde(default)] + icon: Option, + trust_level: String, + #[serde(default)] + capabilities: Vec, + contributes: RawContributes, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEngines { + #[serde(default)] + idea: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawContributes { + #[serde(default)] + menus: Vec, + #[serde(default)] + menu_items: Vec, + #[serde(default)] + layouts: Vec, + #[serde(default)] + mcp_servers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMenu { + id: String, + label: String, + top_level: bool, + #[serde(default)] + order: Option, + #[serde(default)] + icon: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMenuItem { + id: String, + target_menu_id: String, + label: String, + command: String, + #[serde(default)] + order: Option, + #[serde(default)] + icon: Option, + #[serde(default)] + when: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawLayout { + #[serde(rename = "type")] + layout_type: String, + label: String, + component: String, + #[serde(default)] + order: Option, + #[serde(default)] + icon: Option, + #[serde(default)] + when: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMcpServer { + id: String, + display_name: String, + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::BTreeMap, + #[serde(default)] + cwd: Option, + transport: String, + #[serde(default)] + auto_start: bool, + #[serde(default)] + allow_absolute_command: bool, +} + +impl PluginManifestValidator for JsonPluginManifestValidator { + fn validate( + &self, + bytes: &[u8], + _package_root: &domain::PluginPackageRef, + ) -> Result { + let raw: RawManifest = + serde_json::from_slice(bytes).map_err(|e| PluginManifestError::Json(e.to_string()))?; + if raw.idea_plugin_manifest_version != 1 { + return Err(PluginManifestError::Invalid( + "ideaPluginManifestVersion must be 1".to_owned(), + )); + } + if raw.display_name.trim().is_empty() { + return Err(PluginManifestError::Invalid( + "displayName is required".to_owned(), + )); + } + if raw.trust_level != "full" { + return Err(PluginManifestError::Invalid( + "trustLevel must be full in v1".to_owned(), + )); + } + if let Some(range) = &raw.engines.idea { + if !engine_allows(range, &self.idea_version) { + return Err(PluginManifestError::IncompatibleEngine(range.clone())); + } + } + let id = PluginId::new(raw.id).map_err(|e| PluginManifestError::Invalid(e.to_string()))?; + let version = domain::PluginVersion::new(raw.version) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; + let main = domain::RelativePath::new(raw.main) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; + if !(main.as_str().ends_with(".js") || main.as_str().ends_with(".mjs")) { + return Err(PluginManifestError::Invalid( + "main must point to a .js or .mjs file".to_owned(), + )); + } + let icon = raw + .icon + .map(domain::RelativePath::new) + .transpose() + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; + let capabilities = raw + .capabilities + .into_iter() + .map(|c| match c.as_str() { + "ui" => Ok(domain::PluginCapability::Ui), + "mcp" => Ok(domain::PluginCapability::Mcp), + _ => Err(PluginManifestError::Invalid(format!( + "unknown capability: {c}" + ))), + }) + .collect::, _>>()?; + let contributes = validate_contributes(raw.contributes)?; + Ok(PluginManifest { + idea_plugin_manifest_version: 1, + id, + display_name: raw.display_name, + publisher: raw.publisher, + version, + description: raw.description, + engine_idea: raw.engines.idea, + main, + icon, + trust_level: PluginTrustLevel::Full, + capabilities, + contributes, + }) + } +} + +fn validate_contributes(raw: RawContributes) -> Result { + let mut seen = HashSet::new(); + let mut insert = |id: &str| { + if !seen.insert(id.to_owned()) { + Err(PluginManifestError::Invalid(format!( + "duplicate contribution id: {id}" + ))) + } else { + Ok(()) + } + }; + let menus = raw + .menus + .into_iter() + .map(|m| { + insert(&m.id)?; + if !m.top_level { + return Err(PluginManifestError::Invalid( + "menus[].topLevel must be true".to_owned(), + )); + } + Ok(domain::PluginTopLevelMenuContribution { + id: m.id, + label: m.label, + top_level: true, + order: m.order, + icon: m + .icon + .map(domain::RelativePath::new) + .transpose() + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, + }) + }) + .collect::, _>>()?; + let menu_items = raw + .menu_items + .into_iter() + .map(|m| { + insert(&m.id)?; + Ok(domain::PluginMenuItemContribution { + id: m.id, + target_menu_id: m.target_menu_id, + label: m.label, + command: domain::PluginCommandId::new(m.command) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, + order: m.order, + icon: m + .icon + .map(domain::RelativePath::new) + .transpose() + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, + when: m.when, + }) + }) + .collect::, _>>()?; + let layouts = raw + .layouts + .into_iter() + .map(|l| { + insert(&l.layout_type)?; + if l.component.trim().is_empty() { + return Err(PluginManifestError::Invalid( + "layouts[].component is required".to_owned(), + )); + } + Ok(domain::PluginLayoutContribution { + layout_type: domain::PluginLayoutType::new(l.layout_type) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, + label: l.label, + component: l.component, + order: l.order, + icon: l + .icon + .map(domain::RelativePath::new) + .transpose() + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, + when: l.when, + }) + }) + .collect::, _>>()?; + let mcp_servers = raw + .mcp_servers + .into_iter() + .map(|s| { + insert(&s.id)?; + if s.transport != "stdio" { + return Err(PluginManifestError::Invalid( + "mcpServers[].transport must be stdio".to_owned(), + )); + } + if !s.allow_absolute_command && looks_absolute(&s.command) { + return Err(PluginManifestError::Invalid( + "absolute MCP command requires allowAbsoluteCommand=true".to_owned(), + )); + } + if !looks_absolute(&s.command) { + domain::RelativePath::new(s.command.clone()) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; + } + if let Some(cwd) = &s.cwd { + if cwd != "${pluginRoot}" + && !cwd.contains("${appDataDir}") + && !cwd.contains("${pluginRoot}") + { + domain::RelativePath::new(cwd.clone()) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?; + } + } + Ok(domain::PluginMcpServerContribution { + id: domain::PluginMcpServerId::new(s.id) + .map_err(|e| PluginManifestError::Invalid(e.to_string()))?, + display_name: s.display_name, + command: s.command, + args: s.args, + env: s.env.into_iter().collect(), + cwd: s.cwd, + transport: s.transport, + auto_start: s.auto_start, + allow_absolute_command: s.allow_absolute_command, + }) + }) + .collect::, _>>()?; + Ok(PluginContributionSet { + menus, + menu_items, + layouts, + mcp_servers, + }) +} + +fn looks_absolute(path: &str) -> bool { + path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':') +} + +fn engine_allows(range: &str, current: &str) -> bool { + let cur = parse_version_tuple(current).unwrap_or((0, 0, 0)); + range.split_whitespace().all(|part| { + if let Some(v) = part.strip_prefix(">=") { + parse_version_tuple(v).is_some_and(|min| cur >= min) + } else if let Some(v) = part.strip_prefix('>') { + parse_version_tuple(v).is_some_and(|min| cur > min) + } else if let Some(v) = part.strip_prefix("<=") { + parse_version_tuple(v).is_some_and(|max| cur <= max) + } else if let Some(v) = part.strip_prefix('<') { + parse_version_tuple(v).is_some_and(|max| cur < max) + } else if let Some(v) = part.strip_prefix('=') { + parse_version_tuple(v).is_some_and(|eq| cur == eq) + } else { + true + } + }) +} + +fn parse_version_tuple(raw: &str) -> Option<(u64, u64, u64)> { + let core = raw.split_once('-').map_or(raw, |(a, _)| a); + let mut parts = core.split('.'); + Some(( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ports::{EventStream, PluginPackageStore, PluginRegistryStore, PluginStoreError}; + use std::collections::HashMap; + use std::sync::Mutex; + + fn validator() -> JsonPluginManifestValidator { + JsonPluginManifestValidator::new("0.3.0") + } + + fn valid_manifest() -> Vec { + br#"{ + "ideaPluginManifestVersion": 1, + "id": "dev.acme.gitgraph", + "displayName": "Git Graph", + "publisher": "Acme", + "version": "1.2.3", + "engines": {"idea": ">=0.1.0 <1.0.0"}, + "main": "dist/index.js", + "trustLevel": "full", + "capabilities": ["ui", "mcp"], + "contributes": { + "menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}], + "menuItems": [{"id":"dev.acme.open","targetMenuId":"panels","label":"Open","command":"dev.acme.open"}], + "layouts": [{"type":"dev.acme.layout","label":"Graph","component":"Graph"}], + "mcpServers": [{"id":"dev.acme.mcp","displayName":"Tools","command":"servers/tool","transport":"stdio","autoStart":true}] + } + }"#.to_vec() + } + + fn plugin_id() -> PluginId { + PluginId::new("dev.acme.gitgraph").unwrap() + } + + fn content_hash(raw: &str) -> ContentHash { + ContentHash::new(raw).unwrap() + } + + struct FakePackages { + manifests: Mutex>>, + staged: Mutex>, + removed: Mutex>, + } + + impl FakePackages { + fn with_manifest(bytes: Vec) -> Self { + let mut manifests = HashMap::new(); + manifests.insert("dev.acme.gitgraph".to_owned(), bytes); + Self { + manifests: Mutex::new(manifests), + staged: Mutex::new(Some(StagedPluginPackage { + root: "/stage/plugin".to_owned(), + source: PluginInstallSource::Directory { + path_label: "/source/plugin".to_owned(), + }, + content_hash: content_hash("abc123"), + })), + removed: Mutex::new(Vec::new()), + } + } + } + + #[async_trait::async_trait] + impl PluginPackageStore for FakePackages { + async fn list_installed(&self) -> Result, PluginStoreError> { + Ok(self + .manifests + .lock() + .unwrap() + .keys() + .map(|id| domain::PluginPackageRef { + plugin_id: Some(PluginId::new(id.clone()).unwrap()), + root: format!("/installed/{id}"), + }) + .collect()) + } + + async fn read_manifest( + &self, + package: &domain::PluginPackageRef, + ) -> Result { + let key = package + .plugin_id + .as_ref() + .map_or("dev.acme.gitgraph", PluginId::as_str); + self.manifests + .lock() + .unwrap() + .get(key) + .cloned() + .map(|bytes| PluginManifestBytes { bytes }) + .ok_or(PluginStoreError::NotFound) + } + + async fn install_from_archive( + &self, + _archive: &LocalPath, + ) -> Result { + self.staged + .lock() + .unwrap() + .take() + .ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned())) + } + + async fn install_from_directory( + &self, + _dir: &LocalPath, + ) -> Result { + self.staged + .lock() + .unwrap() + .take() + .ok_or_else(|| PluginStoreError::Invalid("missing staged package".to_owned())) + } + + async fn commit_install( + &self, + staged: StagedPluginPackage, + plugin_id: &PluginId, + ) -> Result { + self.manifests + .lock() + .unwrap() + .insert(plugin_id.as_str().to_owned(), valid_manifest()); + Ok(domain::PluginPackageRef { + plugin_id: Some(plugin_id.clone()), + root: staged.root, + }) + } + + async fn remove_package( + &self, + plugin_id: &PluginId, + ) -> Result { + self.removed + .lock() + .unwrap() + .push(plugin_id.as_str().to_owned()); + Ok(RemovalOutcome::Removed) + } + + fn bundle_url( + &self, + plugin_id: &PluginId, + entry: &domain::RelativePath, + hash: &ContentHash, + ) -> Result { + Ok(domain::PluginBundleUrl::new(format!( + "idea-plugin://{}/current/{}/{}", + plugin_id.as_str(), + hash.as_str(), + entry.as_str() + ))) + } + + fn app_data_dir_label(&self) -> Option { + Some("/app-data".to_owned()) + } + } + + #[derive(Default)] + struct FakeRegistry { + registry: Mutex, + } + + #[async_trait::async_trait] + impl PluginRegistryStore for FakeRegistry { + async fn load_registry(&self) -> Result { + Ok(self.registry.lock().unwrap().clone()) + } + + async fn save_registry( + &self, + registry: &domain::PluginRegistry, + ) -> Result<(), PluginRegistryError> { + *self.registry.lock().unwrap() = registry.clone(); + Ok(()) + } + } + + #[derive(Default)] + struct FakeEvents { + events: Mutex>, + } + + impl EventBus for FakeEvents { + fn publish(&self, event: DomainEvent) { + self.events.lock().unwrap().push(event); + } + + fn subscribe(&self) -> EventStream { + Box::new(std::iter::empty()) + } + } + + #[derive(Default)] + struct FakeMcp { + reconciles: Mutex>>, + stops: Mutex>, + } + + #[async_trait::async_trait] + impl PluginMcpSupervisor for FakeMcp { + async fn reconcile( + &self, + active_servers: Vec, + ) -> Result { + self.reconciles.lock().unwrap().push(active_servers.clone()); + Ok(domain::PluginMcpStatusSet { + servers: active_servers + .into_iter() + .map(|spec| domain::PluginMcpStatus { + identity: spec.identity, + running: true, + error: None, + }) + .collect(), + }) + } + + async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> { + self.stops + .lock() + .unwrap() + .push(plugin_id.as_str().to_owned()); + Ok(()) + } + } + + fn registry_with(state: PluginLifecycleState) -> domain::PluginRegistry { + domain::PluginRegistry { + version: 1, + plugins: vec![PluginRegistryEntry { + id: plugin_id(), + lifecycle_state: state, + source: PluginInstallSource::Directory { + path_label: "/source/plugin".to_owned(), + }, + content_hash: content_hash("abc123"), + restart_required: false, + error: None, + }], + } + } + + #[test] + fn validates_manifest_v1() { + let m = validator() + .validate( + &valid_manifest(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into(), + }, + ) + .unwrap(); + assert_eq!(m.id.as_str(), "dev.acme.gitgraph"); + assert_eq!(m.contributes.layouts.len(), 1); + assert_eq!(m.contributes.mcp_servers.len(), 1); + } + + #[test] + fn rejects_unsafe_main_path_and_non_full_trust() { + let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + value["main"] = serde_json::json!("../dist/index.js"); + assert!(validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into() + } + ) + .is_err()); + value["main"] = serde_json::json!("dist/index.js"); + value["trustLevel"] = serde_json::json!("sandbox"); + assert!(validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into() + } + ) + .is_err()); + } + + #[test] + fn rejects_duplicate_contribution_ids() { + let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + value["contributes"]["layouts"][0]["type"] = serde_json::json!("dev.acme.open"); + assert!(validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into() + } + ) + .is_err()); + } + + #[tokio::test] + async fn runtime_catalog_excludes_disabled_and_pending_uninstall_plugins() { + for state in [ + PluginLifecycleState::Disabled, + PluginLifecycleState::PendingUninstall, + PluginLifecycleState::Invalid, + ] { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(state)), + }); + let usecase = + ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())); + + let catalog = usecase.execute().await.unwrap(); + + assert!(catalog.plugins.is_empty(), "{state:?} must not be active"); + } + } + + #[tokio::test] + async fn reconcile_mcp_uses_only_enabled_auto_start_servers_with_plugin_identity() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let mcp = Arc::new(FakeMcp::default()); + let usecase = + ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone()); + + let statuses = usecase.execute().await.unwrap(); + + assert_eq!(statuses.servers.len(), 1); + assert_eq!( + statuses.servers[0].identity, + "plugin:dev.acme.gitgraph:dev.acme.mcp" + ); + let reconciles = mcp.reconciles.lock().unwrap(); + assert_eq!(reconciles.len(), 1); + assert_eq!( + reconciles[0][0].command, + "/installed/dev.acme.gitgraph/servers/tool" + ); + assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph"); + } + + #[tokio::test] + async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::PendingUninstall)), + }); + let mcp = Arc::new(FakeMcp::default()); + let usecase = + ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone()); + + let statuses = usecase.execute().await.unwrap(); + + assert!(statuses.servers.is_empty()); + let reconciles = mcp.reconciles.lock().unwrap(); + assert_eq!(reconciles.len(), 1); + assert!(reconciles[0].is_empty()); + } + + #[tokio::test] + async fn reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs() { + let mut manifest: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + manifest["contributes"]["mcpServers"][0]["command"] = + serde_json::json!("${appDataDir}/plugin-tools/gitgraph"); + manifest["contributes"]["mcpServers"][0]["args"] = + serde_json::json!(["--cache", "${appDataDir}/cache", "--root", "${pluginRoot}"]); + manifest["contributes"]["mcpServers"][0]["env"] = serde_json::json!({ + "PLUGIN_CACHE": "${appDataDir}/cache/dev.acme.gitgraph", + "PLUGIN_ROOT": "${pluginRoot}" + }); + manifest["contributes"]["mcpServers"][0]["cwd"] = + serde_json::json!("${appDataDir}/work/dev.acme.gitgraph"); + let packages = Arc::new(FakePackages::with_manifest( + serde_json::to_vec(&manifest).unwrap(), + )); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let mcp = Arc::new(FakeMcp::default()); + let usecase = + ReconcilePluginMcpServers::new(packages, registry, Arc::new(validator()), mcp.clone()); + + usecase.execute().await.unwrap(); + + let reconciles = mcp.reconciles.lock().unwrap(); + let spec = &reconciles[0][0]; + assert_eq!(spec.command, "/app-data/plugin-tools/gitgraph"); + assert_eq!( + spec.args, + vec![ + "--cache".to_owned(), + "/app-data/cache".to_owned(), + "--root".to_owned(), + "/installed/dev.acme.gitgraph".to_owned() + ] + ); + assert!(spec.env.contains(&( + "PLUGIN_CACHE".to_owned(), + "/app-data/cache/dev.acme.gitgraph".to_owned() + ))); + assert!(spec.env.contains(&( + "PLUGIN_ROOT".to_owned(), + "/installed/dev.acme.gitgraph".to_owned() + ))); + assert_eq!(spec.cwd, "/app-data/work/dev.acme.gitgraph"); + } + + #[tokio::test] + async fn disable_stops_plugin_and_removes_it_from_runtime_catalog() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let events = Arc::new(FakeEvents::default()); + let mcp = Arc::new(FakeMcp::default()); + let disable = SetPluginEnabled::new( + packages.clone(), + registry.clone(), + Arc::new(validator()), + events.clone(), + mcp.clone(), + ); + + let admin = disable + .execute(SetPluginEnabledInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + enabled: false, + }) + .await + .unwrap(); + + assert!(!admin.enabled); + assert_eq!(admin.lifecycle_state, PluginLifecycleState::Disabled); + assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); + assert!(events.events.lock().unwrap().iter().any(|event| matches!( + event, + DomainEvent::PluginDisabled { + restart_required: true, + .. + } + ))); + + let runtime = + ListPluginRuntimeContributions::new(packages, registry, Arc::new(validator())) + .execute() + .await + .unwrap(); + assert!(runtime.plugins.is_empty()); + } + + #[tokio::test] + async fn uninstall_removes_registry_package_and_stops_mcp() { + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let events = Arc::new(FakeEvents::default()); + let mcp = Arc::new(FakeMcp::default()); + let uninstall = UninstallPlugin::new( + packages.clone(), + registry.clone(), + events.clone(), + mcp.clone(), + ); + + let result = uninstall + .execute(UninstallPluginInput { + plugin_id: "dev.acme.gitgraph".to_owned(), + }) + .await + .unwrap(); + + assert_eq!(result.removal_outcome, RemovalOutcome::Removed); + assert!(result.restart_required); + assert!(registry.load_registry().await.unwrap().plugins.is_empty()); + assert_eq!(&*packages.removed.lock().unwrap(), &["dev.acme.gitgraph"]); + assert_eq!(&*mcp.stops.lock().unwrap(), &["dev.acme.gitgraph"]); + assert!(events.events.lock().unwrap().iter().any(|event| matches!( + event, + DomainEvent::PluginUninstalled { + restart_required: true, + .. + } + ))); + } +} diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index 2a6caa7..ddf792d 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -629,6 +629,7 @@ fn leaf_state(fs: &FakeFs, node: NodeId) -> Option<(Option, bool)> { Some((l.conversation_id.clone(), l.agent_was_running)) } LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } @@ -645,6 +646,7 @@ fn leaf_engine_session(fs: &FakeFs, node: NodeId) -> Option> { match n { LayoutNode::Leaf(l) if l.id == target => Some(l.engine_session_id.clone()), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } diff --git a/crates/application/tests/reconcile_layouts.rs b/crates/application/tests/reconcile_layouts.rs index f2bc89e..2b916ef 100644 --- a/crates/application/tests/reconcile_layouts.rs +++ b/crates/application/tests/reconcile_layouts.rs @@ -218,6 +218,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option { match node { LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } diff --git a/crates/application/tests/snapshot_running_agents.rs b/crates/application/tests/snapshot_running_agents.rs index a768ac5..af567f5 100644 --- a/crates/application/tests/snapshot_running_agents.rs +++ b/crates/application/tests/snapshot_running_agents.rs @@ -222,6 +222,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option { match node { LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 8a65b3e..c944491 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -20,6 +20,253 @@ use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, Turn pub use crate::ticket_dto::*; +// --------------------------------------------------------------------------- +// Plugins (#43) +// --------------------------------------------------------------------------- + +/// Plugin contribution summary DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginContributionSummaryDto { + /// Top-level menus count. + pub top_level_menus: usize, + /// Menu items count. + pub menu_items: usize, + /// Layouts count. + pub layouts: usize, + /// MCP servers count. + pub mcp_servers: usize, +} + +impl From for PluginContributionSummaryDto { + fn from(value: application::PluginContributionSummary) -> Self { + Self { + top_level_menus: value.top_level_menus, + menu_items: value.menu_items, + layouts: value.layouts, + mcp_servers: value.mcp_servers, + } + } +} + +/// Admin plugin DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginAdminDto { + /// Plugin id. + pub id: String, + /// Display name. + pub display_name: String, + /// Publisher. + pub publisher: Option, + /// Version. + pub version: String, + /// Description. + pub description: Option, + /// Icon URL. + pub icon_url: Option, + /// Source kind. + pub source_kind: String, + /// Source label. + pub source_label: Option, + /// Lifecycle state. + pub lifecycle_state: domain::PluginLifecycleState, + /// Enabled flag. + pub enabled: bool, + /// Pending enable state. + pub pending_enable_state: Option, + /// Pending uninstall flag. + pub pending_uninstall: bool, + /// Restart required flag. + pub restart_required: bool, + /// Trust level. + pub trust_level: domain::PluginTrustLevel, + /// Contribution summary. + pub contribution_summary: PluginContributionSummaryDto, + /// Optional error. + pub error: Option, +} + +impl From for PluginAdminDto { + fn from(value: application::PluginAdmin) -> Self { + Self { + id: value.id, + display_name: value.display_name, + publisher: value.publisher, + version: value.version, + description: value.description, + icon_url: value.icon_url, + source_kind: value.source_kind, + source_label: value.source_label, + lifecycle_state: value.lifecycle_state, + enabled: value.enabled, + pending_enable_state: value.pending_enable_state, + pending_uninstall: value.pending_uninstall, + restart_required: value.restart_required, + trust_level: value.trust_level, + contribution_summary: value.contribution_summary.into(), + error: value.error, + } + } +} + +/// Review request DTO. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPluginPackageDto { + /// Source kind: `archive` or `directory`. + pub source_kind: String, + /// Local source path. + pub path: String, +} + +/// Plugin review DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginReviewDto { + /// Manifest id. + pub id: String, + /// Display name. + pub display_name: String, + /// Publisher. + pub publisher: Option, + /// Version. + pub version: String, + /// Description. + pub description: Option, + /// Source kind. + pub source_kind: String, + /// Source label. + pub source_label: Option, + /// Content hash. + pub content_hash: String, + /// Trust level. + pub trust_level: domain::PluginTrustLevel, + /// Summary. + pub contribution_summary: PluginContributionSummaryDto, + /// Manifest contributions. + pub contributes: domain::PluginContributionSet, +} + +impl From for PluginReviewDto { + fn from(value: application::PluginReview) -> Self { + Self { + id: value.manifest.id.as_str().to_owned(), + display_name: value.manifest.display_name, + publisher: value.manifest.publisher, + version: value.manifest.version.as_str().to_owned(), + description: value.manifest.description, + source_kind: value.source.kind().to_owned(), + source_label: Some(value.source.label().to_owned()), + content_hash: value.content_hash, + trust_level: value.trust_level, + contribution_summary: value.contribution_summary.into(), + contributes: value.manifest.contributes, + } + } +} + +/// Plugin install result DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstallResultDto { + /// Installed plugin. + pub plugin: PluginAdminDto, + /// Review. + pub review: PluginReviewDto, + /// Restart required. + pub restart_required: bool, +} + +impl From for PluginInstallResultDto { + fn from(value: application::PluginInstallResult) -> Self { + Self { + plugin: value.plugin.into(), + review: value.review.into(), + restart_required: value.restart_required, + } + } +} + +/// Plugin uninstall result DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginUninstallResultDto { + /// Plugin id. + pub plugin_id: String, + /// Removal outcome. + pub removal_outcome: domain::RemovalOutcome, + /// Restart required. + pub restart_required: bool, +} + +impl From for PluginUninstallResultDto { + fn from(value: application::UninstallPluginResult) -> Self { + Self { + plugin_id: value.plugin_id, + removal_outcome: value.removal_outcome, + restart_required: value.restart_required, + } + } +} + +/// Runtime catalog DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimeContributionCatalogDto { + /// Runtime plugins. + pub plugins: Vec, +} + +/// Runtime plugin DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRuntimePluginDto { + /// Plugin id. + pub id: String, + /// Display name. + pub display_name: String, + /// Publisher. + pub publisher: Option, + /// Version. + pub version: String, + /// Bundle URL. + pub bundle_url: String, + /// Icon URL. + pub icon_url: Option, + /// Content hash. + pub content_hash: String, + /// Contributions. + pub contributes: domain::PluginContributionSet, +} + +impl From for PluginRuntimeContributionCatalogDto { + fn from(value: application::PluginRuntimeCatalog) -> Self { + Self { + plugins: value + .plugins + .into_iter() + .map(PluginRuntimePluginDto::from) + .collect(), + } + } +} + +impl From for PluginRuntimePluginDto { + fn from(value: application::PluginRuntimePlugin) -> Self { + Self { + id: value.id, + display_name: value.display_name, + publisher: value.publisher, + version: value.version, + bundle_url: value.bundle_url, + icon_url: value.icon_url, + content_hash: value.content_hash, + contributes: value.contributes, + } + } +} + /// Request DTO for the `health` command. #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 7ad3b24..060c0e4 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -119,6 +119,60 @@ pub enum DomainEventDto { /// Project id (UUID string). project_id: String, }, + /// A plugin was installed. + #[serde(rename_all = "camelCase")] + PluginInstalled { + /// Plugin id. + plugin_id: String, + /// Installed version. + version: String, + }, + /// A plugin was enabled. + #[serde(rename_all = "camelCase")] + PluginEnabled { + /// Plugin id. + plugin_id: String, + }, + /// A plugin was disabled. + #[serde(rename_all = "camelCase")] + PluginDisabled { + /// Plugin id. + plugin_id: String, + /// Whether a restart is needed for full JS purge. + restart_required: bool, + }, + /// A plugin was uninstalled. + #[serde(rename_all = "camelCase")] + PluginUninstalled { + /// Plugin id. + plugin_id: String, + /// Whether a restart is needed for full JS purge. + restart_required: bool, + }, + /// A plugin failed to load. + #[serde(rename_all = "camelCase")] + PluginLoadFailed { + /// Plugin id. + plugin_id: String, + /// Failure reason. + reason: String, + }, + /// A plugin MCP server started. + #[serde(rename_all = "camelCase")] + PluginMcpServerStarted { + /// Plugin id. + plugin_id: String, + /// Server id. + server_id: String, + }, + /// A plugin MCP server stopped. + #[serde(rename_all = "camelCase")] + PluginMcpServerStopped { + /// Plugin id. + plugin_id: String, + /// Server id. + server_id: String, + }, /// An agent was launched. #[serde(rename_all = "camelCase")] AgentLaunched { @@ -677,6 +731,45 @@ impl From<&DomainEvent> for DomainEventDto { DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated { project_id: project_id.to_string(), }, + DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled { + plugin_id: plugin_id.to_string(), + version: version.as_str().to_owned(), + }, + DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled { + plugin_id: plugin_id.to_string(), + }, + DomainEvent::PluginDisabled { + plugin_id, + restart_required, + } => Self::PluginDisabled { + plugin_id: plugin_id.to_string(), + restart_required: *restart_required, + }, + DomainEvent::PluginUninstalled { + plugin_id, + restart_required, + } => Self::PluginUninstalled { + plugin_id: plugin_id.to_string(), + restart_required: *restart_required, + }, + DomainEvent::PluginLoadFailed { plugin_id, reason } => Self::PluginLoadFailed { + plugin_id: plugin_id.to_string(), + reason: reason.clone(), + }, + DomainEvent::PluginMcpServerStarted { + plugin_id, + server_id, + } => Self::PluginMcpServerStarted { + plugin_id: plugin_id.to_string(), + server_id: server_id.as_str().to_owned(), + }, + DomainEvent::PluginMcpServerStopped { + plugin_id, + server_id, + } => Self::PluginMcpServerStopped { + plugin_id: plugin_id.to_string(), + server_id: server_id.as_str().to_owned(), + }, DomainEvent::AgentLaunched { agent_id, session_id, diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 0e8825e..ed93535 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -24,27 +24,30 @@ use application::{ EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, - InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, - ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, - ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, - LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, - LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, - MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, - OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, - ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, - ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, - ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, - ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, - RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, + InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, + JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, + ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, + ListModelServers, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, + ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, + ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, + ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, + RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, + ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, + RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, - SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, - StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, - UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions, - UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, - UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, + SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, + SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, + UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, + UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -53,9 +56,10 @@ use domain::ports::{ BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, - PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, - SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, - WakeError, WakeReason, WindowStateStore, + PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, + PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, + Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, + ToolInvoker, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -73,10 +77,11 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, - FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, - FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, - FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, + ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, + FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, + FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, + FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, + FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, @@ -1062,6 +1067,29 @@ pub struct BackendCore { pub retry_background_task: Arc, /// Store handle used by `list_background_tasks` to read the task read-model. pub background_task_store: Arc, + // --- Plugins (#43) --- + /// Review a local plugin package before install. + pub review_plugin_package: Arc, + /// Install a plugin archive. + pub install_plugin_from_archive: Arc, + /// Install a plugin directory snapshot. + pub install_plugin_from_directory: Arc, + /// List installed plugins for admin UI. + pub list_plugins: Arc, + /// Enable or disable a plugin. + pub set_plugin_enabled: Arc, + /// Uninstall a plugin. + pub uninstall_plugin: Arc, + /// List runtime contributions for UI bootstrap. + pub list_plugin_runtime_contributions: Arc, + /// Reconcile external MCP plugin servers. + pub reconcile_plugin_mcp_servers: Arc, + /// Package store exposed for the Tauri asset protocol adapter. + pub plugin_package_store: Arc, + /// Registry store exposed for the Tauri asset protocol adapter. + pub plugin_registry_store: Arc, + /// Manifest validator exposed for the Tauri asset protocol adapter. + pub plugin_manifest_validator: Arc, // --- Templates & sync (L7) --- /// Create a template in the global store. pub create_template: Arc, @@ -1233,6 +1261,17 @@ impl BackendCore { let pair_attempt_limiter_port = Arc::clone(&pair_attempt_limiter) as Arc; let events_port = Arc::clone(&event_bus) as Arc; + let plugin_packages = Arc::new(FsPluginPackageStore::new(app_data_dir.clone())); + let plugin_registry = Arc::new(FsPluginRegistryStore::new(app_data_dir.clone())); + let plugin_validator = + Arc::new(JsonPluginManifestValidator::new(env!("CARGO_PKG_VERSION"))); + let plugin_mcp_supervisor = Arc::new(ExternalMcpPluginSupervisor::new()); + let plugin_package_store = Arc::clone(&plugin_packages) as Arc; + let plugin_registry_store = Arc::clone(&plugin_registry) as Arc; + let plugin_manifest_validator = + Arc::clone(&plugin_validator) as Arc; + let plugin_mcp_supervisor_port = + Arc::clone(&plugin_mcp_supervisor) as Arc; // --- Use cases (ports injected as Arc) --- let health = Arc::new(HealthUseCase::new( @@ -2089,6 +2128,53 @@ impl BackendCore { Arc::clone(&background_runner) as Arc, Arc::clone(&spawn_background_command), )); + let review_plugin_package = Arc::new(ReviewPluginPackage::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_manifest_validator), + )); + let list_plugins = Arc::new(ListPlugins::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + )); + let install_plugin_from_archive = Arc::new(InstallPluginFromArchive::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + Arc::clone(&events_port), + Arc::clone(&plugin_mcp_supervisor_port), + )); + let install_plugin_from_directory = Arc::new(InstallPluginFromDirectory::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + Arc::clone(&events_port), + Arc::clone(&plugin_mcp_supervisor_port), + )); + let set_plugin_enabled = Arc::new(SetPluginEnabled::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + Arc::clone(&events_port), + Arc::clone(&plugin_mcp_supervisor_port), + )); + let uninstall_plugin = Arc::new(UninstallPlugin::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&events_port), + Arc::clone(&plugin_mcp_supervisor_port), + )); + let list_plugin_runtime_contributions = Arc::new(ListPluginRuntimeContributions::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + )); + let reconcile_plugin_mcp_servers = Arc::new(ReconcilePluginMcpServers::new( + Arc::clone(&plugin_package_store), + Arc::clone(&plugin_registry_store), + Arc::clone(&plugin_manifest_validator), + Arc::clone(&plugin_mcp_supervisor_port), + )); let background_wake = Arc::new(AgentWakeService::new( Arc::clone(&mediated_inbox) as Arc, Arc::clone(&input_mediator), @@ -2613,6 +2699,17 @@ impl BackendCore { cancel_background_task, retry_background_task, background_task_store: Arc::clone(&background_tasks_port), + review_plugin_package, + install_plugin_from_archive, + install_plugin_from_directory, + list_plugins, + set_plugin_enabled, + uninstall_plugin, + list_plugin_runtime_contributions, + reconcile_plugin_mcp_servers, + plugin_package_store: Arc::clone(&plugin_packages), + plugin_registry_store: Arc::clone(&plugin_registry_store), + plugin_manifest_validator: Arc::clone(&plugin_manifest_validator), ticket_tool_binder, template_tool_binder, } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index d096926..9e44c52 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -10,6 +10,7 @@ use crate::ids::{ use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion}; use crate::mailbox::TicketId; use crate::memory::MemorySlug; +use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion}; use crate::sprint::{SprintOrder, SprintVersion}; use crate::template::TemplateVersion; @@ -41,6 +42,53 @@ pub enum DomainEvent { /// The new project. project_id: ProjectId, }, + /// A plugin was installed. + PluginInstalled { + /// Plugin id. + plugin_id: PluginId, + /// Installed version. + version: PluginVersion, + }, + /// A plugin was enabled. + PluginEnabled { + /// Plugin id. + plugin_id: PluginId, + }, + /// A plugin was disabled. + PluginDisabled { + /// Plugin id. + plugin_id: PluginId, + /// Whether a restart is needed for full JS purge. + restart_required: bool, + }, + /// A plugin was uninstalled. + PluginUninstalled { + /// Plugin id. + plugin_id: PluginId, + /// Whether a restart is needed for full JS purge. + restart_required: bool, + }, + /// A plugin failed to load. + PluginLoadFailed { + /// Plugin id. + plugin_id: PluginId, + /// Failure reason. + reason: String, + }, + /// A plugin MCP server started. + PluginMcpServerStarted { + /// Plugin id. + plugin_id: PluginId, + /// Server id. + server_id: PluginMcpServerId, + }, + /// A plugin MCP server stopped. + PluginMcpServerStopped { + /// Plugin id. + plugin_id: PluginId, + /// Server id. + server_id: PluginMcpServerId, + }, /// An agent was launched in a terminal. AgentLaunched { /// The agent. diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index d3a66a9..236acc2 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::ids::{AgentId, NodeId, SessionId, TabId, WindowId}; +use crate::plugin::{PluginId, PluginLayoutType}; /// Direction of a [`SplitContainer`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -160,6 +161,24 @@ pub struct GridContainer { pub cells: Vec, } +/// Persisted custom layout cell provided by an installed plugin. +/// +/// The domain stores only the stable provider identity, layout type, and opaque +/// state. It never stores or resolves the React component used to render it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomPluginLayoutCell { + /// Node identifier. + pub id: NodeId, + /// Provider plugin id. + pub plugin_id: PluginId, + /// Persisted layout type declared by the provider plugin. + pub layout_type: PluginLayoutType, + /// Opaque plugin-owned state. + #[serde(default)] + pub state: serde_json::Value, +} + /// A node in the layout tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type", content = "node")] @@ -170,6 +189,8 @@ pub enum LayoutNode { Split(SplitContainer), /// A spreadsheet-style grid. Grid(GridContainer), + /// A plugin-provided custom layout leaf. + CustomPluginLayout(CustomPluginLayoutCell), } /// The root of a layout (one per tab). @@ -663,6 +684,7 @@ impl LayoutTree { out.push((leaf.id, agent)); } } + LayoutNode::CustomPluginLayout(_) => {} LayoutNode::Split(split) => { for child in &split.children { walk(&child.node, out); @@ -770,6 +792,7 @@ impl LayoutTree { match n { LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)), LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), } @@ -784,6 +807,7 @@ impl LayoutTree { match node { LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)), LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), } @@ -797,6 +821,7 @@ impl LayoutTree { fn map_node(node: &LayoutNode, f: &mut impl FnMut(&LayoutNode) -> LayoutNode) -> LayoutNode { let rebuilt = match node { LayoutNode::Leaf(_) => node.clone(), + LayoutNode::CustomPluginLayout(_) => node.clone(), LayoutNode::Split(split) => LayoutNode::Split(SplitContainer { id: split.id, direction: split.direction, @@ -844,6 +869,7 @@ fn validate_node( } Ok(()) } + LayoutNode::CustomPluginLayout(_) => Ok(()), LayoutNode::Split(split) => { if split.children.is_empty() { return Err(LayoutError::EmptySplit); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 6744dc2..1267076 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -54,6 +54,7 @@ pub mod memory_harvest; pub mod model_server; pub mod orchestrator; pub mod permission; +pub mod plugin; pub mod ports; pub mod profile; pub mod project; @@ -178,10 +179,10 @@ pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; pub use git::GitRepository; pub use layout::{ - Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, - PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, - PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot, - Workspace, WINDOW_STATE_SNAPSHOT_VERSION, + CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode, + LayoutTree, LeafCell, PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, + PersistedWindowSize, PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, + WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION, }; pub use events::{DomainEvent, OrchestrationSource}; @@ -193,6 +194,16 @@ pub use permission::{ ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION, }; +pub use plugin::{ + ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId, + PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource, + PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest, + PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus, + PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry, + PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, + RelativePath, RemovalOutcome, StagedPluginPackage, +}; + pub use sandbox::{ compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus, @@ -210,11 +221,13 @@ pub use ports::{ EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore, - IssueStoreError, LiveStateStore, McpToolPermissionStore, MemoryError, MemoryQuery, + IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, - ModelArtifactResolution, Output, OutputStream, PermissionStore, PreparedContext, ProcessError, - ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, - RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, SprintStore, - SprintStoreError, StoreError, StructuredSessionEnvironment, + ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, + PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, + PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, + PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, + PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, + SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, TemplateStore, WindowStateStore, }; diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs new file mode 100644 index 0000000..0887780 --- /dev/null +++ b/crates/domain/src/plugin.rs @@ -0,0 +1,681 @@ +//! Plugin domain model and validated manifest image. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// Plugin domain validation error. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PluginError { + /// A required field is empty or malformed. + #[error("invalid plugin field {field}: {reason}")] + InvalidField { + /// Field name. + field: &'static str, + /// Human-readable reason. + reason: String, + }, + /// A path escaped the plugin root. + #[error("invalid plugin path {field}: {path}")] + InvalidPath { + /// Field name. + field: &'static str, + /// Offending path. + path: String, + }, + /// A contribution id is duplicated within the same plugin. + #[error("duplicate contribution id: {0}")] + DuplicateContribution(String), +} + +/// Stable plugin identifier. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginId(String); + +impl PluginId { + /// Validates and creates a plugin id. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + let valid_len = (3..=128).contains(&raw.len()); + let mut chars = raw.chars(); + let first = chars.next().unwrap_or('\0'); + let valid_first = first.is_ascii_lowercase() || first.is_ascii_digit(); + let valid_rest = + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-'); + if valid_len && valid_first && valid_rest { + Ok(Self(raw)) + } else { + Err(PluginError::InvalidField { + field: "id", + reason: "expected [a-z0-9][a-z0-9.-]{2,127}".to_owned(), + }) + } + } + + /// Returns the raw id. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for PluginId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// SemVer-like plugin version. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginVersion(String); + +impl PluginVersion { + /// Validates and creates a version. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + let core = raw.split_once('-').map_or(raw.as_str(), |(a, _)| a); + let parts: Vec<&str> = core.split('.').collect(); + let ok = parts.len() == 3 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars().all(|c| c.is_ascii_digit()) + && (p == &"0" || !p.starts_with('0')) + }); + if ok { + Ok(Self(raw)) + } else { + Err(PluginError::InvalidField { + field: "version", + reason: "expected semantic version MAJOR.MINOR.PATCH".to_owned(), + }) + } + } + + /// Returns the raw version. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Relative path inside a plugin package. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RelativePath(String); + +impl RelativePath { + /// Validates a manifest path as relative, normalized, and root-confined. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + if is_safe_relative_path(&raw) { + Ok(Self(raw.replace('\\', "/"))) + } else { + Err(PluginError::InvalidPath { + field: "path", + path: raw, + }) + } + } + + /// Returns the relative path. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn is_safe_relative_path(raw: &str) -> bool { + if raw.is_empty() + || raw.starts_with('/') + || raw.starts_with('\\') + || raw.contains('\0') + || raw.contains(':') + { + return false; + } + raw.replace('\\', "/") + .split('/') + .all(|p| !p.is_empty() && p != "." && p != "..") +} + +/// Content hash of a plugin bundle/package. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ContentHash(String); + +impl ContentHash { + /// Creates a content hash value. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_hexdigit()) { + Ok(Self(raw)) + } else { + Err(PluginError::InvalidField { + field: "contentHash", + reason: "expected non-empty hexadecimal hash".to_owned(), + }) + } + } + + /// Returns the hash. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// URL exposed to the frontend for a plugin bundle or asset. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginBundleUrl(String); + +impl PluginBundleUrl { + /// Creates a bundle URL. + #[must_use] + pub fn new(raw: impl Into) -> Self { + Self(raw.into()) + } + + /// Returns the URL. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Package reference managed by the store adapter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPackageRef { + /// Plugin id if the package is already committed. + pub plugin_id: Option, + /// Opaque adapter-owned root path label. + pub root: String, +} + +/// Staged package produced by install/review. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StagedPluginPackage { + /// Opaque staging root path. + pub root: String, + /// Original source label. + pub source: PluginInstallSource, + /// Hash of package contents at staging time. + pub content_hash: ContentHash, +} + +/// Plugin install source persisted for admin display. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PluginInstallSource { + /// Local archive source. + Archive { + /// Human-readable path label. + path_label: String, + }, + /// Local directory source. + Directory { + /// Human-readable path label. + path_label: String, + }, +} + +impl PluginInstallSource { + /// Returns the stable DTO source kind. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Archive { .. } => "archive", + Self::Directory { .. } => "directory", + } + } + + /// Returns the optional label. + #[must_use] + pub fn label(&self) -> &str { + match self { + Self::Archive { path_label } | Self::Directory { path_label } => path_label, + } + } +} + +/// Plugin lifecycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PluginLifecycleState { + /// Installed and enabled. + Enabled, + /// Installed and disabled. + Disabled, + /// Enable requested. + PendingEnable, + /// Disable requested. + PendingDisable, + /// Uninstall requested. + PendingUninstall, + /// Invalid manifest/incompatible engine. + Invalid, +} + +impl PluginLifecycleState { + /// Whether the plugin can expose runtime contributions. + #[must_use] + pub fn is_runtime_active(self) -> bool { + matches!(self, Self::Enabled | Self::PendingEnable) + } +} + +/// Trust level supported by v1. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginTrustLevel { + /// Full-trust plugin. + Full, +} + +/// Declared plugin capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginCapability { + /// UI bundle/contributions. + Ui, + /// External MCP server declarations. + Mcp, +} + +/// Plugin command id. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginCommandId(String); + +impl PluginCommandId { + /// Validates and creates a command id. + pub fn new(raw: impl Into) -> Result { + let raw = raw.into(); + if raw.len() >= 3 + && raw + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')) + { + Ok(Self(raw)) + } else { + Err(PluginError::InvalidField { + field: "command", + reason: "invalid command id".to_owned(), + }) + } + } + + /// Returns the raw id. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Plugin layout type. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginLayoutType(String); + +impl PluginLayoutType { + /// Validates and creates a layout type. + pub fn new(raw: impl Into) -> Result { + PluginCommandId::new(raw).map(|v| Self(v.0)) + } + + /// Returns the raw type. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Plugin MCP server id. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PluginMcpServerId(String); + +impl PluginMcpServerId { + /// Validates and creates a server id. + pub fn new(raw: impl Into) -> Result { + PluginCommandId::new(raw).map(|v| Self(v.0)) + } + + /// Returns the raw id. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Top-level menu contribution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginTopLevelMenuContribution { + /// Menu id. + pub id: String, + /// Display label. + pub label: String, + /// Must be true for this contribution kind. + pub top_level: bool, + /// Sort order. + #[serde(default)] + pub order: Option, + /// Optional icon path. + #[serde(default)] + pub icon: Option, +} + +/// Menu item contribution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMenuItemContribution { + /// Item id. + pub id: String, + /// Target menu id. + pub target_menu_id: String, + /// Display label. + pub label: String, + /// Command id. + pub command: PluginCommandId, + /// Sort order. + #[serde(default)] + pub order: Option, + /// Optional icon path. + #[serde(default)] + pub icon: Option, + /// Optional declarative condition. + #[serde(default)] + pub when: Option, +} + +/// Layout contribution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginLayoutContribution { + /// Persisted layout type. + #[serde(rename = "type")] + pub layout_type: PluginLayoutType, + /// Display label. + pub label: String, + /// Component export name. + pub component: String, + /// Sort order. + #[serde(default)] + pub order: Option, + /// Optional icon path. + #[serde(default)] + pub icon: Option, + /// Optional declarative condition. + #[serde(default)] + pub when: Option, +} + +/// MCP server contribution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMcpServerContribution { + /// Server id. + pub id: PluginMcpServerId, + /// Display name. + pub display_name: String, + /// Executable command path or absolute command when allowed. + pub command: String, + /// Arguments. + #[serde(default)] + pub args: Vec, + /// Environment variables. + #[serde(default)] + pub env: Vec<(String, String)>, + /// Working directory. + #[serde(default)] + pub cwd: Option, + /// Transport, v1 only `stdio`. + pub transport: String, + /// Auto start flag. + #[serde(default)] + pub auto_start: bool, + /// Development-only escape hatch for absolute commands. + #[serde(default)] + pub allow_absolute_command: bool, +} + +/// Validated contribution set. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginContributionSet { + /// Top-level menus. + #[serde(default)] + pub menus: Vec, + /// Menu items. + #[serde(default)] + pub menu_items: Vec, + /// Layout contributions. + #[serde(default)] + pub layouts: Vec, + /// MCP server contributions. + #[serde(default)] + pub mcp_servers: Vec, +} + +/// Validated plugin manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManifest { + /// Manifest schema version. + pub idea_plugin_manifest_version: u32, + /// Plugin id. + pub id: PluginId, + /// Display name. + pub display_name: String, + /// Publisher. + #[serde(default)] + pub publisher: Option, + /// Version. + pub version: PluginVersion, + /// Description. + #[serde(default)] + pub description: Option, + /// Engine constraint for IdeA. + #[serde(default)] + pub engine_idea: Option, + /// Main ESM bundle. + pub main: RelativePath, + /// Optional icon path. + #[serde(default)] + pub icon: Option, + /// Trust level. + pub trust_level: PluginTrustLevel, + /// Capabilities. + #[serde(default)] + pub capabilities: Vec, + /// Contributions. + pub contributes: PluginContributionSet, +} + +/// Persisted registry entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRegistryEntry { + /// Plugin id. + pub id: PluginId, + /// Lifecycle state. + pub lifecycle_state: PluginLifecycleState, + /// Source. + pub source: PluginInstallSource, + /// Content hash. + pub content_hash: ContentHash, + /// Restart required marker. + #[serde(default)] + pub restart_required: bool, + /// Optional error. + #[serde(default)] + pub error: Option, +} + +/// Persisted global registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRegistry { + /// Schema version. + pub version: u32, + /// Registry entries. + pub plugins: Vec, +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self { + version: 1, + plugins: Vec::new(), + } + } +} + +impl PluginRegistry { + /// Finds a plugin entry. + #[must_use] + pub fn find(&self, id: &PluginId) -> Option<&PluginRegistryEntry> { + self.plugins.iter().find(|p| &p.id == id) + } + + /// Upserts a registry entry, preserving id uniqueness. + pub fn upsert(&mut self, entry: PluginRegistryEntry) { + if let Some(slot) = self.plugins.iter_mut().find(|p| p.id == entry.id) { + *slot = entry; + } else { + self.plugins.push(entry); + } + } + + /// Removes an entry. + pub fn remove(&mut self, id: &PluginId) -> Option { + let index = self.plugins.iter().position(|p| &p.id == id)?; + Some(self.plugins.remove(index)) + } +} + +/// Admin descriptor assembled from manifest + registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDescriptor { + /// Manifest. + pub manifest: PluginManifest, + /// Registry entry. + pub registry: PluginRegistryEntry, +} + +impl PluginDescriptor { + /// Returns true if runtime contributions are active. + #[must_use] + pub fn exposes_runtime_contributions(&self) -> bool { + self.registry.lifecycle_state.is_runtime_active() + } +} + +/// Result of removing package files. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RemovalOutcome { + /// Files were removed. + Removed, + /// Files were moved to trash for later cleanup. + Tombstoned, + /// No package files existed. + NotFound, +} + +/// Resolved MCP server spec passed to the supervisor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMcpServerSpec { + /// Plugin id. + pub plugin_id: PluginId, + /// Manifest server id. + pub server_id: PluginMcpServerId, + /// Stable external identity `plugin::`. + pub identity: String, + /// Display name. + pub display_name: String, + /// Command. + pub command: String, + /// Args. + pub args: Vec, + /// Env. + pub env: Vec<(String, String)>, + /// Cwd. + pub cwd: String, + /// Transport. + pub transport: String, +} + +/// MCP server status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMcpStatus { + /// Identity. + pub identity: String, + /// Running flag. + pub running: bool, + /// Optional error. + #[serde(default)] + pub error: Option, +} + +/// MCP status set. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginMcpStatusSet { + /// Statuses. + pub servers: Vec, +} + +/// Custom plugin layout persisted in a layout leaf's opaque content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomPluginLayout { + /// Provider plugin id. + pub plugin_id: PluginId, + /// Provider display name if known. + #[serde(default)] + pub provider_plugin_display_name: Option, + /// Layout type. + pub layout_type: PluginLayoutType, + /// Opaque plugin state. + #[serde(default)] + pub state: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_id_pattern_is_enforced() { + assert!(PluginId::new("dev.acme.gitgraph").is_ok()); + assert!(PluginId::new("De.acme").is_err()); + assert!(PluginId::new("a").is_err()); + } + + #[test] + fn relative_paths_cannot_escape_root() { + assert_eq!( + RelativePath::new("dist/index.js").unwrap().as_str(), + "dist/index.js" + ); + assert!(RelativePath::new("../dist/index.js").is_err()); + assert!(RelativePath::new("/tmp/index.js").is_err()); + assert!(RelativePath::new("dist/../index.js").is_err()); + } + + #[test] + fn disabled_and_pending_uninstall_are_not_runtime_active() { + assert!(!PluginLifecycleState::Disabled.is_runtime_active()); + assert!(!PluginLifecycleState::PendingUninstall.is_runtime_active()); + assert!(PluginLifecycleState::Enabled.is_runtime_active()); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index ff41e03..6d13d9f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -49,6 +49,11 @@ use crate::model_server::{ HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus, }; use crate::permission::ProjectPermissions; +use crate::plugin::{ + ContentHash, PluginBundleUrl, PluginId, PluginManifest, PluginMcpServerSpec, + PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome, + StagedPluginPackage, +}; use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; @@ -279,6 +284,166 @@ impl RemotePath { } } +/// Opaque local path supplied by a driving adapter. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LocalPath(pub String); + +impl LocalPath { + /// Wraps a raw path. + #[must_use] + pub fn new(p: impl Into) -> Self { + Self(p.into()) + } + + /// Returns the path as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Raw manifest bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManifestBytes { + /// Bytes of `idea-plugin.json`. + pub bytes: Vec, +} + +/// Plugin package store errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PluginStoreError { + /// Package was not found. + #[error("plugin package not found")] + NotFound, + /// Invalid input/source. + #[error("invalid plugin package: {0}")] + Invalid(String), + /// I/O error. + #[error("plugin package I/O error: {0}")] + Io(String), + /// Serialization or archive error. + #[error("plugin package format error: {0}")] + Format(String), +} + +/// Plugin registry errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PluginRegistryError { + /// I/O error. + #[error("plugin registry I/O error: {0}")] + Io(String), + /// Serialization error. + #[error("plugin registry serialization error: {0}")] + Serialization(String), +} + +/// Manifest validation errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PluginManifestError { + /// Invalid JSON. + #[error("invalid plugin manifest JSON: {0}")] + Json(String), + /// Invalid manifest data. + #[error("invalid plugin manifest: {0}")] + Invalid(String), + /// Incompatible engine. + #[error("incompatible IdeA engine: {0}")] + IncompatibleEngine(String), +} + +/// Plugin MCP errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PluginMcpError { + /// Process/supervisor failure. + #[error("plugin MCP error: {0}")] + Process(String), +} + +/// Store for installed plugin packages under the global app data directory. +#[async_trait] +pub trait PluginPackageStore: Send + Sync { + /// Lists committed package roots. + async fn list_installed(&self) -> Result, PluginStoreError>; + + /// Reads `idea-plugin.json` from a package root. + async fn read_manifest( + &self, + package: &PluginPackageRef, + ) -> Result; + + /// Stages an archive for validation. + async fn install_from_archive( + &self, + archive: &LocalPath, + ) -> Result; + + /// Stages a directory snapshot for validation. + async fn install_from_directory( + &self, + dir: &LocalPath, + ) -> Result; + + /// Commits a staged install into `installed/`. + async fn commit_install( + &self, + staged: StagedPluginPackage, + plugin_id: &PluginId, + ) -> Result; + + /// Removes a committed package. + async fn remove_package( + &self, + plugin_id: &PluginId, + ) -> Result; + + /// Builds a protocol URL for a bundle/asset. + fn bundle_url( + &self, + plugin_id: &PluginId, + entry: &RelativePath, + hash: &ContentHash, + ) -> Result; + + /// Returns the global application data directory label when the adapter can + /// expose it for manifest variable substitution. + fn app_data_dir_label(&self) -> Option { + None + } +} + +/// Store for the global plugin registry. +#[async_trait] +pub trait PluginRegistryStore: Send + Sync { + /// Loads the registry, returning an empty document if missing. + async fn load_registry(&self) -> Result; + + /// Persists the registry. + async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError>; +} + +/// Manifest parser and validator. +pub trait PluginManifestValidator: Send + Sync { + /// Validates raw manifest bytes. + fn validate( + &self, + bytes: &[u8], + package_root: &PluginPackageRef, + ) -> Result; +} + +/// Supervisor for external MCP servers declared by plugins. +#[async_trait] +pub trait PluginMcpSupervisor: Send + Sync { + /// Reconciles the desired active server set. + async fn reconcile( + &self, + active_servers: Vec, + ) -> Result; + + /// Stops every server owned by one plugin. + async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError>; +} + /// A single directory entry returned by [`FileSystem::list`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirEntry { diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs index 18d4d85..74b1fe3 100644 --- a/crates/domain/tests/layout.rs +++ b/crates/domain/tests/layout.rs @@ -44,6 +44,7 @@ fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option { match n { LayoutNode::Leaf(l) if l.id == id => Some(l.clone()), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)), } @@ -235,6 +236,7 @@ fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option Some(l.session), LayoutNode::Leaf(_) => None, + LayoutNode::CustomPluginLayout(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)), } diff --git a/crates/domain/tests/serde_roundtrip.rs b/crates/domain/tests/serde_roundtrip.rs index 0cabddb..9addd53 100644 --- a/crates/domain/tests/serde_roundtrip.rs +++ b/crates/domain/tests/serde_roundtrip.rs @@ -4,10 +4,10 @@ mod helpers; use domain::{ - Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction, - LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef, - SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, - TemplateVersion, WeightedChild, + Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, + CustomPluginLayoutCell, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry, + MarkdownDoc, PluginId, PluginLayoutType, Project, ProjectPath, RemoteRef, SessionStrategy, + Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild, }; use helpers::{node, session}; use uuid::Uuid; @@ -472,3 +472,29 @@ fn leaf_with_agent_roundtrip_and_omits_null() { "agent field should be omitted when None; json was {json2}" ); } + +#[test] +fn custom_plugin_layout_roundtrips_with_opaque_state() { + let tree = LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell { + id: node(43), + plugin_id: PluginId::new("dev.acme.gitgraph").unwrap(), + layout_type: PluginLayoutType::new("dev.acme.gitgraph.layout").unwrap(), + state: serde_json::json!({"branch":"main","zoom":2}), + })); + + assert_eq!(roundtrip(&tree), tree); + let json = serde_json::to_string(&tree).unwrap(); + assert!( + json.contains("\"type\":\"customPluginLayout\""), + "json was {json}" + ); + assert!( + json.contains("\"pluginId\":\"dev.acme.gitgraph\""), + "json was {json}" + ); + assert!( + json.contains("\"layoutType\":\"dev.acme.gitgraph.layout\""), + "json was {json}" + ); + assert!(json.contains("\"state\""), "json was {json}"); +} diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 057bafd..c7176f4 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -21,6 +21,8 @@ futures-util = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } # Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) : # le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex # vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur @@ -49,6 +51,7 @@ fastembed = { version = "5", default-features = false, features = ["hf-hub-rustl [target.'cfg(target_os = "linux")'.dependencies] landlock = "0.4.5" + [features] # Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…). # OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index e30d656..6ed2e21 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -30,6 +30,7 @@ pub mod model_server; pub mod orchestrator; pub mod pair_attempt_limiter; pub mod permission; +pub mod plugin; pub mod process; pub mod pty; pub mod ratelimit; @@ -80,6 +81,7 @@ pub use orchestrator::{ }; pub use pair_attempt_limiter::InMemoryPairAttemptLimiter; pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; +pub use plugin::{ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore}; pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; pub use ratelimit::RateLimitParser; diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs new file mode 100644 index 0000000..f7ac343 --- /dev/null +++ b/crates/infrastructure/src/plugin/mod.rs @@ -0,0 +1,755 @@ +//! Filesystem plugin stores and external MCP supervisor. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use domain::ports::{ + LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, + PluginRegistryError, PluginRegistryStore, PluginStoreError, +}; +use domain::{ + ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec, + PluginMcpStatus, PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, + RemovalOutcome, StagedPluginPackage, +}; +use sha2::{Digest, Sha256}; +use tokio::process::Child; + +const REGISTRY_FILE: &str = "registry.json"; +const MANIFEST_FILE: &str = "idea-plugin.json"; + +/// Filesystem package store under app-data `plugins/`. +#[derive(Debug, Clone)] +pub struct FsPluginPackageStore { + root: PathBuf, +} + +impl FsPluginPackageStore { + /// Builds the store. + #[must_use] + pub fn new(app_data_dir: impl Into) -> Self { + Self { + root: app_data_dir.into().join("plugins"), + } + } + + fn installed_dir(&self) -> PathBuf { + self.root.join("installed") + } + + /// Returns the global plugin store root. + #[must_use] + pub fn plugins_root(&self) -> PathBuf { + self.root.clone() + } + + /// Returns the managed directory for one installed plugin id. + #[must_use] + pub fn installed_plugin_dir(&self, plugin_id: &PluginId) -> PathBuf { + self.installed_dir().join(plugin_id.as_str()) + } + + fn staging_dir(&self) -> PathBuf { + self.root.join("_staging") + } + + fn trash_dir(&self) -> PathBuf { + self.root.join("_trash") + } + + fn package_root(&self, package: &PluginPackageRef) -> PathBuf { + match &package.plugin_id { + Some(id) => self.installed_dir().join(id.as_str()), + None => PathBuf::from(&package.root), + } + } + + /// Resolves an asset path if the request is root-confined. + pub fn resolve_asset_path( + &self, + plugin_id: &PluginId, + path: &RelativePath, + ) -> Result { + let root = self.installed_dir().join(plugin_id.as_str()); + let candidate = root.join(path.as_str()); + let canonical_root = root + .canonicalize() + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + let canonical = candidate + .canonicalize() + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + if canonical.starts_with(canonical_root) { + Ok(canonical) + } else { + Err(PluginStoreError::Invalid( + "plugin asset escapes package root".to_owned(), + )) + } + } + + fn stage_root(&self) -> Result { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| PluginStoreError::Io(e.to_string()))? + .as_nanos(); + let path = self.staging_dir().join(format!("stage-{ts}")); + fs::create_dir_all(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?; + Ok(path) + } +} + +#[async_trait] +impl PluginPackageStore for FsPluginPackageStore { + async fn list_installed(&self) -> Result, PluginStoreError> { + let dir = self.installed_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in fs::read_dir(dir).map_err(|e| PluginStoreError::Io(e.to_string()))? { + let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?; + if !entry + .file_type() + .map_err(|e| PluginStoreError::Io(e.to_string()))? + .is_dir() + { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if let Ok(id) = PluginId::new(name) { + out.push(PluginPackageRef { + plugin_id: Some(id), + root: entry.path().to_string_lossy().into_owned(), + }); + } + } + Ok(out) + } + + async fn read_manifest( + &self, + package: &PluginPackageRef, + ) -> Result { + let bytes = fs::read(self.package_root(package).join(MANIFEST_FILE)).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + PluginStoreError::NotFound + } else { + PluginStoreError::Io(e.to_string()) + } + })?; + Ok(PluginManifestBytes { bytes }) + } + + async fn install_from_archive( + &self, + archive: &LocalPath, + ) -> Result { + let stage = self.stage_root()?; + let status = std::process::Command::new("unzip") + .arg("-q") + .arg(archive.as_str()) + .arg("-d") + .arg(&stage) + .status() + .map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?; + if !status.success() { + return Err(PluginStoreError::Format(format!( + "unzip exited with status {status}" + ))); + } + ensure_manifest(&stage)?; + let content_hash = hash_dir(&stage)?; + Ok(StagedPluginPackage { + root: stage.to_string_lossy().into_owned(), + source: PluginInstallSource::Archive { + path_label: archive.as_str().to_owned(), + }, + content_hash, + }) + } + + async fn install_from_directory( + &self, + dir: &LocalPath, + ) -> Result { + let source = PathBuf::from(dir.as_str()); + if !source.is_dir() { + return Err(PluginStoreError::NotFound); + } + let stage = self.stage_root()?; + copy_dir_all(&source, &stage)?; + ensure_manifest(&stage)?; + let content_hash = hash_dir(&stage)?; + Ok(StagedPluginPackage { + root: stage.to_string_lossy().into_owned(), + source: PluginInstallSource::Directory { + path_label: dir.as_str().to_owned(), + }, + content_hash, + }) + } + + async fn commit_install( + &self, + staged: StagedPluginPackage, + plugin_id: &PluginId, + ) -> Result { + fs::create_dir_all(self.installed_dir()) + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + let target = self.installed_dir().join(plugin_id.as_str()); + let tmp = self + .installed_dir() + .join(format!(".{}-new", plugin_id.as_str())); + if tmp.exists() { + fs::remove_dir_all(&tmp).map_err(|e| PluginStoreError::Io(e.to_string()))?; + } + fs::rename(&staged.root, &tmp).or_else(|_| { + copy_dir_all(Path::new(&staged.root), &tmp)?; + fs::remove_dir_all(&staged.root).map_err(|e| PluginStoreError::Io(e.to_string())) + })?; + if target.exists() { + fs::remove_dir_all(&target).map_err(|e| PluginStoreError::Io(e.to_string()))?; + } + fs::rename(&tmp, &target).map_err(|e| PluginStoreError::Io(e.to_string()))?; + Ok(PluginPackageRef { + plugin_id: Some(plugin_id.clone()), + root: target.to_string_lossy().into_owned(), + }) + } + + async fn remove_package( + &self, + plugin_id: &PluginId, + ) -> Result { + let target = self.installed_dir().join(plugin_id.as_str()); + if !target.exists() { + return Ok(RemovalOutcome::NotFound); + } + match fs::remove_dir_all(&target) { + Ok(()) => Ok(RemovalOutcome::Removed), + Err(_) => { + fs::create_dir_all(self.trash_dir()) + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| PluginStoreError::Io(e.to_string()))? + .as_secs(); + let tombstone = self + .trash_dir() + .join(format!("{}-{ts}", plugin_id.as_str())); + fs::rename(&target, tombstone).map_err(|e| PluginStoreError::Io(e.to_string()))?; + Ok(RemovalOutcome::Tombstoned) + } + } + } + + fn bundle_url( + &self, + plugin_id: &PluginId, + entry: &RelativePath, + hash: &ContentHash, + ) -> Result { + Ok(PluginBundleUrl::new(format!( + "idea-plugin://{}/current/{}{}{}", + plugin_id.as_str(), + hash.as_str(), + "/", + entry.as_str() + ))) + } + + fn app_data_dir_label(&self) -> Option { + self.root + .parent() + .map(|p| p.to_string_lossy().into_owned()) + } +} + +fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> { + if root.join(MANIFEST_FILE).is_file() { + Ok(()) + } else { + Err(PluginStoreError::NotFound) + } +} + +fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> { + fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?; + for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? { + let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?; + let ty = entry + .file_type() + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + let dest = target.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&entry.path(), &dest)?; + } else if ty.is_file() { + fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?; + } + } + Ok(()) +} + +fn hash_dir(root: &Path) -> Result { + let mut files = Vec::new(); + collect_files(root, &mut files)?; + files.sort(); + let mut hasher = Sha256::new(); + for path in files { + let rel = path + .strip_prefix(root) + .map_err(|e| PluginStoreError::Io(e.to_string()))? + .to_string_lossy() + .replace('\\', "/"); + hasher.update(rel.as_bytes()); + hasher.update([0]); + let mut file = fs::File::open(&path).map_err(|e| PluginStoreError::Io(e.to_string()))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf) + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + hasher.update(buf); + hasher.update([0]); + } + ContentHash::new(hex::encode(hasher.finalize())) + .map_err(|e| PluginStoreError::Invalid(e.to_string())) +} + +fn collect_files(root: &Path, files: &mut Vec) -> Result<(), PluginStoreError> { + for entry in fs::read_dir(root).map_err(|e| PluginStoreError::Io(e.to_string()))? { + let entry = entry.map_err(|e| PluginStoreError::Io(e.to_string()))?; + let ty = entry + .file_type() + .map_err(|e| PluginStoreError::Io(e.to_string()))?; + if ty.is_dir() { + collect_files(&entry.path(), files)?; + } else if ty.is_file() { + files.push(entry.path()); + } + } + Ok(()) +} + +/// Filesystem registry store. +#[derive(Debug, Clone)] +pub struct FsPluginRegistryStore { + root: PathBuf, +} + +impl FsPluginRegistryStore { + /// Builds the store. + #[must_use] + pub fn new(app_data_dir: impl Into) -> Self { + Self { + root: app_data_dir.into().join("plugins"), + } + } + + fn path(&self) -> PathBuf { + self.root.join(REGISTRY_FILE) + } +} + +#[async_trait] +impl PluginRegistryStore for FsPluginRegistryStore { + async fn load_registry(&self) -> Result { + match fs::read(self.path()) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| PluginRegistryError::Serialization(e.to_string())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PluginRegistry::default()), + Err(e) => Err(PluginRegistryError::Io(e.to_string())), + } + } + + async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> { + fs::create_dir_all(&self.root).map_err(|e| PluginRegistryError::Io(e.to_string()))?; + let bytes = serde_json::to_vec_pretty(registry) + .map_err(|e| PluginRegistryError::Serialization(e.to_string()))?; + fs::write(self.path(), bytes).map_err(|e| PluginRegistryError::Io(e.to_string())) + } +} + +#[async_trait] +trait ExternalMcpServerHandle: Send { + async fn stop(&mut self) -> Result<(), PluginMcpError>; +} + +#[async_trait] +trait ExternalMcpServerBridge: Send + Sync { + async fn start( + &self, + spec: &PluginMcpServerSpec, + ) -> Result, PluginMcpError>; +} + +struct ProcessMcpServerHandle { + child: Child, +} + +#[async_trait] +impl ExternalMcpServerHandle for ProcessMcpServerHandle { + async fn stop(&mut self) -> Result<(), PluginMcpError> { + self.child + .kill() + .await + .map_err(|e| PluginMcpError::Process(e.to_string())) + } +} + +#[derive(Debug, Default)] +struct StdioExternalMcpServerBridge; + +#[async_trait] +impl ExternalMcpServerBridge for StdioExternalMcpServerBridge { + async fn start( + &self, + spec: &PluginMcpServerSpec, + ) -> Result, PluginMcpError> { + if spec.transport != "stdio" { + return Err(PluginMcpError::Process(format!( + "unsupported plugin MCP transport: {}", + spec.transport + ))); + } + let mut cmd = tokio::process::Command::new(&spec.command); + cmd.args(&spec.args) + .current_dir(&spec.cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let env: BTreeMap<_, _> = spec.env.iter().cloned().collect(); + cmd.envs(env); + let child = cmd + .spawn() + .map_err(|e| PluginMcpError::Process(e.to_string()))?; + Ok(Box::new(ProcessMcpServerHandle { child })) + } +} + +/// External process supervisor for plugin MCP servers. +pub struct ExternalMcpPluginSupervisor { + bridge: Arc, + children: Mutex>>, +} + +impl ExternalMcpPluginSupervisor { + /// Builds the supervisor. + #[must_use] + pub fn new() -> Self { + Self { + bridge: Arc::new(StdioExternalMcpServerBridge), + children: Mutex::new(HashMap::new()), + } + } + + #[cfg(test)] + fn with_bridge(bridge: Arc) -> Self { + Self { + bridge, + children: Mutex::new(HashMap::new()), + } + } +} + +impl Default for ExternalMcpPluginSupervisor { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl PluginMcpSupervisor for ExternalMcpPluginSupervisor { + async fn reconcile( + &self, + active_servers: Vec, + ) -> Result { + let desired: HashSet = active_servers.iter().map(|s| s.identity.clone()).collect(); + let to_stop = { + let children = self + .children + .lock() + .expect("plugin mcp supervisor poisoned"); + children + .keys() + .filter(|id| !desired.contains(*id)) + .cloned() + .collect::>() + }; + for id in to_stop { + let child = self + .children + .lock() + .expect("plugin mcp supervisor poisoned") + .remove(&id); + if let Some(mut child) = child { + let _ = child.stop().await; + } + } + let mut statuses = Vec::new(); + for spec in active_servers { + let already = self + .children + .lock() + .expect("plugin mcp supervisor poisoned") + .contains_key(&spec.identity); + if already { + statuses.push(PluginMcpStatus { + identity: spec.identity, + running: true, + error: None, + }); + continue; + } + match self.bridge.start(&spec).await { + Ok(handle) => { + self.children + .lock() + .expect("plugin mcp supervisor poisoned") + .insert(spec.identity.clone(), handle); + statuses.push(PluginMcpStatus { + identity: spec.identity, + running: true, + error: None, + }); + } + Err(e) => statuses.push(PluginMcpStatus { + identity: spec.identity, + running: false, + error: Some(e.to_string()), + }), + } + } + Ok(PluginMcpStatusSet { servers: statuses }) + } + + async fn stop_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginMcpError> { + let prefix = format!("plugin:{}:", plugin_id.as_str()); + let ids = { + let children = self + .children + .lock() + .expect("plugin mcp supervisor poisoned"); + children + .keys() + .filter(|id| id.starts_with(&prefix)) + .cloned() + .collect::>() + }; + for id in ids { + let child = self + .children + .lock() + .expect("plugin mcp supervisor poisoned") + .remove(&id); + if let Some(mut child) = child { + child.stop().await?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ports::PluginPackageStore; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(label: &str) -> PathBuf { + let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!( + "idea-plugin-test-{label}-{}-{n}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + path + } + + fn write_plugin(root: &Path, main_body: &str) { + fs::create_dir_all(root.join("dist")).unwrap(); + fs::write(root.join("dist/index.js"), main_body).unwrap(); + fs::write( + root.join(MANIFEST_FILE), + r#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#, + ) + .unwrap(); + } + + #[derive(Default)] + struct RecordingBridge { + started: Mutex>, + stopped: Arc>>, + } + + struct RecordingHandle { + identity: String, + stopped: Arc>>, + } + + #[async_trait] + impl ExternalMcpServerHandle for RecordingHandle { + async fn stop(&mut self) -> Result<(), PluginMcpError> { + self.stopped.lock().unwrap().push(self.identity.clone()); + Ok(()) + } + } + + #[async_trait] + impl ExternalMcpServerBridge for RecordingBridge { + async fn start( + &self, + spec: &PluginMcpServerSpec, + ) -> Result, PluginMcpError> { + assert_eq!(spec.transport, "stdio"); + self.started.lock().unwrap().push(spec.clone()); + Ok(Box::new(RecordingHandle { + identity: spec.identity.clone(), + stopped: Arc::clone(&self.stopped), + })) + } + } + + fn mcp_spec(plugin_id: &str, server_id: &str) -> PluginMcpServerSpec { + let plugin_id = PluginId::new(plugin_id).unwrap(); + let server_id = domain::PluginMcpServerId::new(server_id).unwrap(); + PluginMcpServerSpec { + identity: format!("plugin:{}:{}", plugin_id.as_str(), server_id.as_str()), + plugin_id, + server_id, + display_name: "Plugin Tools".to_owned(), + command: "/plugin/servers/tool".to_owned(), + args: vec!["--stdio".to_owned()], + env: vec![("PLUGIN_ROOT".to_owned(), "/plugin".to_owned())], + cwd: "/plugin".to_owned(), + transport: "stdio".to_owned(), + } + } + + #[tokio::test] + async fn installs_directory_snapshot_and_uninstalls_cleanly() { + let app = temp_dir("app"); + let source = temp_dir("source"); + write_plugin(&source, "one"); + let store = FsPluginPackageStore::new(&app); + let staged = store + .install_from_directory(&LocalPath::new(source.to_string_lossy())) + .await + .unwrap(); + assert_ne!(staged.root, source.to_string_lossy()); + let id = PluginId::new("dev.acme.test").unwrap(); + let package = store.commit_install(staged, &id).await.unwrap(); + assert!(PathBuf::from(package.root).join(MANIFEST_FILE).exists()); + assert_eq!( + store.remove_package(&id).await.unwrap(), + RemovalOutcome::Removed + ); + assert!(!app.join("plugins/installed/dev.acme.test").exists()); + let _ = fs::remove_dir_all(app); + let _ = fs::remove_dir_all(source); + } + + #[tokio::test] + async fn content_hash_changes_when_bundle_changes() { + let app = temp_dir("hash-app"); + let source = temp_dir("hash-source"); + write_plugin(&source, "one"); + let store = FsPluginPackageStore::new(&app); + let first = store + .install_from_directory(&LocalPath::new(source.to_string_lossy())) + .await + .unwrap() + .content_hash; + fs::write(source.join("dist/index.js"), "two").unwrap(); + let second = store + .install_from_directory(&LocalPath::new(source.to_string_lossy())) + .await + .unwrap() + .content_hash; + assert_ne!(first, second); + let _ = fs::remove_dir_all(app); + let _ = fs::remove_dir_all(source); + } + + #[tokio::test] + async fn extracts_archive_without_path_escape() { + if std::process::Command::new("zip") + .arg("-h") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + || std::process::Command::new("unzip") + .arg("-h") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_err() + { + return; + } + let app = temp_dir("archive-app"); + let source = temp_dir("archive-source"); + write_plugin(&source, "bundle"); + let archive_path = app.join("plugin.ideaplug"); + { + let status = std::process::Command::new("zip") + .arg("-qr") + .arg(&archive_path) + .arg(".") + .current_dir(&source) + .status() + .unwrap(); + assert!(status.success()); + } + let store = FsPluginPackageStore::new(app.join("data")); + let staged = store + .install_from_archive(&LocalPath::new(archive_path.to_string_lossy())) + .await + .unwrap(); + assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists()); + let _ = fs::remove_dir_all(app); + let _ = fs::remove_dir_all(source); + } + + #[tokio::test] + async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() { + let bridge = Arc::new(RecordingBridge::default()); + let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone()); + let spec = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp"); + + let statuses = supervisor.reconcile(vec![spec.clone()]).await.unwrap(); + + assert_eq!(statuses.servers.len(), 1); + assert_eq!(statuses.servers[0].identity, spec.identity); + assert!(statuses.servers[0].running); + assert_eq!(&*bridge.started.lock().unwrap(), &[spec]); + } + + #[tokio::test] + async fn supervisor_stop_plugin_stops_only_matching_plugin_servers() { + let bridge = Arc::new(RecordingBridge::default()); + let supervisor = ExternalMcpPluginSupervisor::with_bridge(bridge.clone()); + let target = mcp_spec("dev.acme.gitgraph", "dev.acme.gitgraph.mcp"); + let other = mcp_spec("dev.other.tools", "dev.other.tools.mcp"); + supervisor + .reconcile(vec![target.clone(), other.clone()]) + .await + .unwrap(); + + supervisor.stop_plugin(&target.plugin_id).await.unwrap(); + + assert_eq!(&*bridge.stopped.lock().unwrap(), &[target.identity.clone()]); + let statuses = supervisor.reconcile(vec![other.clone()]).await.unwrap(); + assert_eq!(statuses.servers.len(), 1); + assert_eq!(statuses.servers[0].identity, other.identity); + assert_eq!(bridge.started.lock().unwrap().len(), 2); + } +}