Files
IdeA/crates/infrastructure/src/plugin/mod.rs
Blomios 92d0dce2b7 feat(domain,application,infrastructure,backend,app-tauri,frontend): lot utilisateur #350 — animation d'écartement des onglets au drag/reorder, icônes projet N&B colorisées derrière les messages, tools/skills plugin utilisables par les agents autorisés en headless, refs stables des skills assignés (QA verte)
- Tabs (shared/ui): cible de drop matérialisée par écartement before/after pendant le drag/reorder, reset via rAF sans reliquat, clavier et annonces préservés.
- projectIcons: icônes projet en noir et blanc par défaut, colorisées avec la couleur du projet, occupant la cellule et visibles derrière les messages rendus légèrement transparents.
- Permissions plugin headless: PluginMcpServerSpec transporte l'allowlist exacte authorized_tools regroupée par (pluginId, serverId) depuis les PluginToolRef; projection Codex enabled_tools + default_tools_approval_mode="approve" — une permission déjà accordée au niveau projet/template ne déclenche pas de seconde approbation, un tool non assigné reste hors surface appelable; réserves orchestrator/mcp server+tools alignées, dto backend + dto_agents app-tauri.
- Skills: ref stable plugin:<pluginId>:<skillId> exposée dans le contexte agent, idea_skill_read(ref=...) canonique via read_assigned_by_ref, erreur structurée ambiguousSkillName avec candidates; SkillsPanel dédoublonné (android-build-install); agents-dev/L12-skills.md à jour.
- Docs: ARCHITECTURE.md section 22.3 (projection des autorisations agent vers les runtimes plugin).
- QA verte #350 reconfirmée avant commit.
2026-09-13 14:11:31 +02:00

1154 lines
40 KiB
Rust

//! 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<PathBuf>) -> 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<PathBuf, PluginStoreError> {
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<PathBuf, PluginStoreError> {
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<Vec<PluginPackageRef>, 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<LocalPath, PluginStoreError> {
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<PluginManifestBytes, PluginStoreError> {
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<Vec<u8>, 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<StagedPluginPackage, PluginStoreError> {
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<StagedPluginPackage, PluginStoreError> {
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<PluginPackageRef, PluginStoreError> {
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<RemovalOutcome, PluginStoreError> {
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<PluginBundleUrl, PluginStoreError> {
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<String> {
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<ContentHash, PluginStoreError> {
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<PathBuf>) -> 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<PathBuf>) -> 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/<pluginId>/`.
#[derive(Debug, Clone)]
pub struct FsPluginStorageStore {
root: PathBuf,
}
impl FsPluginStorageStore {
/// Builds the store.
#[must_use]
pub fn new(app_data_dir: impl Into<PathBuf>) -> 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<Option<serde_json::Value>, 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<bool, PluginStorageError> {
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<RemovalOutcome, PluginStorageError> {
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<PluginRegistry, PluginRegistryError> {
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<Box<dyn ExternalMcpServerHandle>, PluginMcpError>;
}
type ExternalMcpChildren = HashMap<String, Box<dyn ExternalMcpServerHandle>>;
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<Box<dyn ExternalMcpServerHandle>, 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<dyn ExternalMcpServerBridge>,
children: Mutex<ExternalMcpChildren>,
}
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<dyn ExternalMcpServerBridge>) -> Self {
Self {
bridge,
children: Mutex::new(HashMap::new()),
}
}
fn children(&self) -> Result<MutexGuard<'_, ExternalMcpChildren>, 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<PluginMcpServerSpec>,
) -> Result<PluginMcpStatusSet, PluginMcpError> {
let desired: HashSet<String> = 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::<Vec<_>>()
};
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::<Vec<_>>()
};
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<u32>,
}
fn write_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32(out: &mut Vec<u8>, 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(&central).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<Vec<PluginMcpServerSpec>>,
stopped: Arc<Mutex<Vec<String>>>,
}
struct RecordingHandle {
identity: String,
stopped: Arc<Mutex<Vec<String>>>,
}
#[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<Box<dyn ExternalMcpServerHandle>, 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())
);
}
}