//! Filesystem plugin stores and external MCP supervisor. use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use domain::ports::{ LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore, 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"; const STORAGE_ENTRIES_DIR: &str = "entries"; #[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] struct PluginStorageEntry { key: String, value: serde_json::Value, } /// 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 installed_plugin_root( &self, plugin_id: &PluginId, ) -> Result { let root = self.installed_plugin_dir(plugin_id); let canonical = root.canonicalize().map_err(|error| { if error.kind() == io::ErrorKind::NotFound { PluginStoreError::NotFound } else { PluginStoreError::Io(error.to_string()) } })?; Ok(LocalPath::new(canonical.to_string_lossy().into_owned())) } 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 read_installed_file( &self, plugin_id: &PluginId, path: &RelativePath, ) -> Result, PluginStoreError> { let resolved = self.resolve_asset_path(plugin_id, path)?; fs::read(resolved).map_err(|e| { if e.kind() == io::ErrorKind::NotFound { PluginStoreError::NotFound } else { PluginStoreError::Io(e.to_string()) } }) } async fn install_from_archive( &self, archive: &LocalPath, ) -> Result { let stage = self.stage_root()?; extract_archive_confined(Path::new(archive.as_str()), &stage)?; 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 { self.resolve_asset_path(plugin_id, entry)?; 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 extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> { let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?; let mut archive = zip::ZipArchive::new(file) .map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?; for index in 0..archive.len() { let mut entry = archive .by_index(index) .map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?; let entry_name = entry.name().to_owned(); let enclosed = entry.enclosed_name().ok_or_else(|| { PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}")) })?; if entry .unix_mode() .is_some_and(|mode| mode & 0o170000 == 0o120000) { return Err(PluginStoreError::Invalid(format!( "archive entry symlinks are not allowed: {entry_name}" ))); } let destination = stage.join(&enclosed); if entry.is_dir() { fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?; continue; } if let Some(parent) = destination.parent() { fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?; } let mut out = fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?; io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?; out.flush() .map_err(|e| PluginStoreError::Io(e.to_string()))?; } Ok(()) } 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()))?; } else if ty.is_symlink() { return Err(PluginStoreError::Invalid(format!( "plugin source contains symlink: {}", entry.path().display() ))); } else { return Err(PluginStoreError::Invalid(format!( "plugin source contains unsupported entry: {}", entry.path().display() ))); } } 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()); } else if ty.is_symlink() { return Err(PluginStoreError::Invalid(format!( "plugin package contains symlink: {}", entry.path().display() ))); } else { return Err(PluginStoreError::Invalid(format!( "plugin package contains unsupported entry: {}", entry.path().display() ))); } } 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) } } /// Filesystem plugin-owned storage under app-data `plugins/data//`. #[derive(Debug, Clone)] pub struct FsPluginStorageStore { root: PathBuf, } impl FsPluginStorageStore { /// Builds the store. #[must_use] pub fn new(app_data_dir: impl Into) -> Self { Self { root: app_data_dir.into().join("plugins").join("data"), } } fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf { self.root.join(plugin_id.as_str()) } fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf { let digest = Sha256::digest(key.as_bytes()); self.plugin_dir(plugin_id) .join(STORAGE_ENTRIES_DIR) .join(format!("{}.json", hex::encode(digest))) } } #[async_trait] impl PluginStorageStore for FsPluginStorageStore { async fn get( &self, plugin_id: &PluginId, key: &str, ) -> Result, PluginStorageError> { let path = self.entry_path(plugin_id, key); if !path.exists() { return Ok(None); } let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?; let entry: PluginStorageEntry = serde_json::from_slice(&bytes) .map_err(|e| PluginStorageError::Serialization(e.to_string()))?; if entry.key == key { Ok(Some(entry.value)) } else { Err(PluginStorageError::Invalid( "plugin storage key hash collision".to_owned(), )) } } async fn set( &self, plugin_id: &PluginId, key: &str, value: serde_json::Value, ) -> Result<(), PluginStorageError> { let path = self.entry_path(plugin_id, key); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?; } let entry = PluginStorageEntry { key: key.to_owned(), value, }; let bytes = serde_json::to_vec_pretty(&entry) .map_err(|e| PluginStorageError::Serialization(e.to_string()))?; fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string())) } async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result { let path = self.entry_path(plugin_id, key); match fs::remove_file(path) { Ok(()) => Ok(true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(PluginStorageError::Io(e.to_string())), } } async fn purge_plugin( &self, plugin_id: &PluginId, ) -> Result { let dir = self.plugin_dir(plugin_id); match fs::remove_dir_all(dir) { Ok(()) => Ok(RemovalOutcome::Removed), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound), Err(e) => Err(PluginStorageError::Io(e.to_string())), } } } #[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>; } type ExternalMcpChildren = HashMap>; 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()), } } fn children(&self) -> Result, PluginMcpError> { self.children .lock() .map_err(|_| PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned())) } } 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()?; children .keys() .filter(|id| !desired.contains(*id)) .cloned() .collect::>() }; for id in to_stop { let child = self.children()?.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()?.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) => { match self.children() { Ok(mut children) => { children.insert(spec.identity.clone(), handle); } Err(err) => return Err(err), } 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()?; children .keys() .filter(|id| id.starts_with(&prefix)) .cloned() .collect::>() }; for id in ids { let child = self.children()?.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(); } struct ZipEntrySpec<'a> { name: &'a str, contents: &'a [u8], unix_mode: Option, } fn write_u16(out: &mut Vec, value: u16) { out.extend_from_slice(&value.to_le_bytes()); } fn write_u32(out: &mut Vec, value: u32) { out.extend_from_slice(&value.to_le_bytes()); } fn crc32(bytes: &[u8]) -> u32 { let mut crc = 0xffff_ffffu32; for &byte in bytes { crc ^= u32::from(byte); for _ in 0..8 { let mask = (crc & 1).wrapping_neg(); crc = (crc >> 1) ^ (0xedb8_8320 & mask); } } !crc } fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) { let mut file = fs::File::create(path).unwrap(); let mut central = Vec::new(); let mut offset = 0u32; for entry in entries { let name = entry.name.as_bytes(); let data = entry.contents; let crc = crc32(data); let local_size = 30u32 + name.len() as u32 + data.len() as u32; let mut local = Vec::new(); write_u32(&mut local, 0x0403_4b50); write_u16(&mut local, 20); write_u16(&mut local, 0); write_u16(&mut local, 0); write_u16(&mut local, 0); write_u16(&mut local, 0); write_u32(&mut local, crc); write_u32(&mut local, data.len() as u32); write_u32(&mut local, data.len() as u32); write_u16(&mut local, name.len() as u16); write_u16(&mut local, 0); local.extend_from_slice(name); local.extend_from_slice(data); file.write_all(&local).unwrap(); let mut header = Vec::new(); let version_made_by = if entry.unix_mode.is_some() { (3u16 << 8) | 20 } else { 20 }; let external_attributes = entry.unix_mode.unwrap_or(0) << 16; write_u32(&mut header, 0x0201_4b50); write_u16(&mut header, version_made_by); write_u16(&mut header, 20); write_u16(&mut header, 0); write_u16(&mut header, 0); write_u16(&mut header, 0); write_u16(&mut header, 0); write_u32(&mut header, crc); write_u32(&mut header, data.len() as u32); write_u32(&mut header, data.len() as u32); write_u16(&mut header, name.len() as u16); write_u16(&mut header, 0); write_u16(&mut header, 0); write_u16(&mut header, 0); write_u16(&mut header, 0); write_u32(&mut header, external_attributes); write_u32(&mut header, offset); header.extend_from_slice(name); central.extend_from_slice(&header); offset += local_size; } file.write_all(¢ral).unwrap(); let mut eocd = Vec::new(); write_u32(&mut eocd, 0x0605_4b50); write_u16(&mut eocd, 0); write_u16(&mut eocd, 0); write_u16(&mut eocd, entries.len() as u16); write_u16(&mut eocd, entries.len() as u16); write_u32(&mut eocd, central.len() as u32); write_u32(&mut eocd, offset); write_u16(&mut eocd, 0); file.write_all(&eocd).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(), authorized_tools: Vec::new(), } } #[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() { let app = temp_dir("archive-app"); let archive_path = app.join("plugin.ideaplug"); write_zip( &archive_path, &[ ZipEntrySpec { name: "idea-plugin.json", contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#, unix_mode: Some(0o100644), }, ZipEntrySpec { name: "dist/index.js", contents: b"bundle", unix_mode: Some(0o100644), }, ], ); 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); } #[tokio::test] async fn install_from_directory_rejects_source_symlink() { let app = temp_dir("symlink-app"); let source = temp_dir("symlink-source"); write_plugin(&source, "bundle"); let outside = app.join("outside.txt"); fs::write(&outside, "secret").unwrap(); #[cfg(unix)] std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap(); #[cfg(windows)] std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap(); let store = FsPluginPackageStore::new(&app); let err = store .install_from_directory(&LocalPath::new(source.to_string_lossy())) .await .unwrap_err(); assert!(matches!(err, PluginStoreError::Invalid(_))); assert!(err.to_string().contains("symlink")); 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 install_from_archive_rejects_parent_traversal_without_writing_outside_stage() { let app = temp_dir("traversal-app"); let archive_path = app.join("plugin.ideaplug"); write_zip( &archive_path, &[ ZipEntrySpec { name: "../../../../outside.txt", contents: b"pwned", unix_mode: Some(0o100644), }, ZipEntrySpec { name: "idea-plugin.json", contents: br#"{"ideaPluginManifestVersion":1}"#, unix_mode: Some(0o100644), }, ], ); let store = FsPluginPackageStore::new(&app); let err = store .install_from_archive(&LocalPath::new(archive_path.to_string_lossy())) .await .unwrap_err(); assert!(matches!(err, PluginStoreError::Invalid(_))); assert!(err.to_string().contains("escapes plugin root")); assert!(!app.join("outside.txt").exists()); let _ = fs::remove_dir_all(app); } #[tokio::test] async fn install_from_archive_rejects_symlink_entries() { let app = temp_dir("archive-symlink-app"); let archive_path = app.join("plugin.ideaplug"); write_zip( &archive_path, &[ ZipEntrySpec { name: "idea-plugin.json", contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#, unix_mode: Some(0o100644), }, ZipEntrySpec { name: "dist/link.js", contents: b"/tmp/outside.js", unix_mode: Some(0o120777), }, ], ); let store = FsPluginPackageStore::new(&app); let err = store .install_from_archive(&LocalPath::new(archive_path.to_string_lossy())) .await .unwrap_err(); assert!(matches!(err, PluginStoreError::Invalid(_))); assert!(err.to_string().contains("symlinks are not allowed")); assert!(!app.join("plugins/installed/dev.acme.test").exists()); let _ = fs::remove_dir_all(app); } #[tokio::test] async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() { let app = temp_dir("storage-app"); let store = FsPluginStorageStore::new(&app); let plugin_id = PluginId::new("dev.acme.test").unwrap(); assert_eq!( store.get(&plugin_id, "helloPlugin.launches").await.unwrap(), None ); store .set( &plugin_id, "helloPlugin.launches", serde_json::json!({"count": 1}), ) .await .unwrap(); assert_eq!( store.get(&plugin_id, "helloPlugin.launches").await.unwrap(), Some(serde_json::json!({"count": 1})) ); assert!(app.join("plugins/data/dev.acme.test/entries").is_dir()); assert!(store .delete(&plugin_id, "helloPlugin.launches") .await .unwrap()); assert_eq!( store.get(&plugin_id, "helloPlugin.launches").await.unwrap(), None ); store .set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true)) .await .unwrap(); assert_eq!( store.purge_plugin(&plugin_id).await.unwrap(), RemovalOutcome::Removed ); assert!(!app.join("plugins/data/dev.acme.test").exists()); let _ = fs::remove_dir_all(app); } #[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); } #[tokio::test] async fn supervisor_poisoned_registry_returns_typed_error() { let supervisor = ExternalMcpPluginSupervisor::new(); let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _guard = supervisor.children.lock().unwrap(); panic!("poison plugin mcp supervisor registry"); })); let err = supervisor.reconcile(Vec::new()).await.unwrap_err(); assert_eq!( err, PluginMcpError::Process("plugin mcp supervisor mutex poisoned".to_owned()) ); } }