//! In-memory registry of active plugins. //! //! The `PluginRegistry` is the single source of truth for which plugins //! are loaded in a session. It is built once during `MvpAgent` initialization //! and can be rebuilt via `/plugins reload`. Each session receives a snapshot. use std::collections::HashMap; use std::path::{Path, PathBuf}; use super::discovery::{DiscoveredPlugin, PluginId, PluginOrigin, PluginScope}; /// A loaded plugin with resolved components, ready for use by the session. #[derive(Debug, Clone)] pub struct LoadedPlugin { /// User-facing plugin name (from manifest or directory name). pub name: String, /// Stable internal identity. pub id: PluginId, /// Absolute path to the plugin root directory. pub root: PathBuf, /// Canonical (symlink-resolved) root path. pub canonical_root: PathBuf, /// Where this plugin was discovered. pub scope: PluginScope, /// The concrete discovery source this plugin came from. pub origin: PluginOrigin, /// Whether the plugin is trusted for executable operations (hooks, MCP, /// LSP). Derived from discovery scope: CLI and User plugins are auto-trusted; /// Project plugins require explicit trust grant. pub trusted: bool, /// Whether the plugin is enabled (not in `[plugins].disabled`). pub enabled: bool, /// Plugin version from manifest. pub version: Option, /// Plugin description from manifest. pub description: Option, /// Resolved skill directories. pub skill_dirs: Vec, pub command_dirs: Vec, /// Resolved agent directories. pub agent_dirs: Vec, /// Resolved hooks file path. pub hooks_path: Option, /// Resolved MCP config file path. pub mcp_config_path: Option, /// Resolved LSP config file path. pub lsp_config_path: Option, /// Number of skill subdirectories found. pub skill_count: usize, /// Number of agent files found. pub agent_count: usize, /// Skill names (directory names under skills/). pub skill_names: Vec, /// Agent/persona names (filenames without .md extension). pub agent_names: Vec, /// Whether hooks are present (file or inline). pub has_hooks: bool, /// Number of hook specs defined (file-based + inline). pub hook_count: usize, /// Whether hooks are inline-only (no file-based hooks.json). pub has_inline_hooks_only: bool, /// Number of MCP servers defined. pub mcp_server_count: usize, /// Whether MCP servers are inline-only (no file-based config). pub has_inline_mcp_only: bool, /// Number of LSP servers defined. pub lsp_server_count: usize, /// Whether LSP servers are inline-only (no file-based config). pub has_inline_lsp_only: bool, /// Inline hooks JSON from manifest (when hooks are defined inline, not file-based). pub inline_hooks: Option, /// Inline MCP servers JSON from manifest (when defined inline, not file-based). pub inline_mcp_servers: Option, /// Inline LSP servers JSON from manifest (when defined inline, not file-based). pub inline_lsp_servers: Option, /// Warning if this plugin won a name collision with another plugin. pub conflict: Option, } impl LoadedPlugin { /// Data directory for this plugin: `~/.kigi/plugin-data//`. pub fn data_dir(&self) -> PathBuf { kigi_config::kigi_home() .join("plugin-data") .join(&self.id.0) } /// Plugin root path as a string (for env var substitution). pub fn root_str(&self) -> String { self.root.to_string_lossy().to_string() } /// Plugin data dir path as a string (for env var substitution). pub fn data_dir_str(&self) -> String { self.data_dir().to_string_lossy().to_string() } } /// In-memory registry of active plugins. /// /// Keyed by `plugin_name` (the user-facing namespace). /// Handles enable/disable filtering and MCP server ownership lookups. #[derive(Debug, Clone)] pub struct PluginRegistry { /// Active plugins, keyed by plugin_name. plugins: HashMap, /// Map from MCP server name to the plugin that owns it. mcp_owners: HashMap, /// Per-session dirs from `_meta.pluginDirs` that this registry was built /// with, carried so per-session rebuilds can re-merge them. session_plugin_dirs: Vec, } impl PluginRegistry { /// Build a new registry from discovered plugins. /// /// Applies the `disabled` and `enabled` lists from config. /// Project-scope plugins are disabled by default unless explicitly in the `enabled` list. /// Disabled plugins are still in the registry but their components are /// not loaded into the session. pub fn from_discovered( discovered: Vec, disabled: &[String], enabled: &[String], ) -> Self { let mut plugins = HashMap::new(); let mut mcp_owners = HashMap::new(); for dp in discovered { let name = dp.manifest.name.clone(); // Verify the plugin is in exactly one of enabled/disabled. let in_enabled = enabled.iter().any(|e| e == &name || e == &dp.id.0); let in_disabled = disabled.iter().any(|d| d == &name || d == &dp.id.0); if in_enabled && in_disabled { tracing::warn!( plugin = %name, id = %dp.id.0, "plugin appears in both enabled and disabled lists; disabled takes precedence", ); } else if !in_enabled && !in_disabled { tracing::warn!( plugin = %name, id = %dp.id.0, "plugin missing from both enabled and disabled lists; defaulting to disabled", ); } // Count components let skill_count = count_skill_subdirs(&dp.skill_dirs) + count_md_files(&dp.command_dirs); let agent_count = count_md_files(&dp.agent_dirs); let skill_names = { let mut names = collect_skill_names(&dp.skill_dirs); names.extend(collect_md_names(&dp.command_dirs)); names }; let agent_names = collect_md_names(&dp.agent_dirs); let has_hooks = dp.hooks_path.is_some() || dp.manifest.inline_hooks().is_some(); let has_inline_hooks_only = dp.hooks_path.is_none() && dp.manifest.inline_hooks().is_some(); let mcp_server_names = plugin_mcp_server_names(&dp); let mcp_server_count = mcp_server_names.len(); let hook_count = count_hook_specs(dp.hooks_path.as_deref(), dp.manifest.inline_hooks()); let has_inline_mcp_only = dp.mcp_config_path.is_none() && dp.manifest.inline_mcp_servers().is_some(); let lsp_server_count = count_lsp_servers(&dp); let has_inline_lsp_only = dp.lsp_config_path.is_none() && dp.manifest.inline_lsp_servers().is_some(); // Capture inline data before consuming the manifest let inline_hooks = dp.manifest.inline_hooks().cloned(); let inline_mcp_servers = dp.manifest.inline_mcp_servers().cloned(); let inline_lsp_servers = dp.manifest.inline_lsp_servers().cloned(); // Determine enabled status. // Every plugin should be in either the `enabled` or `disabled` list // (callers use `DiscoveryConfig::populate_plugin_lists` to ensure this). // A plugin is enabled only if it is in the `enabled` list and NOT in // the `disabled` list (disabled takes precedence on conflict). let explicitly_enabled = enabled .iter() .any(|e| e == &dp.id.0 || e == &dp.manifest.name); let enabled = !is_disabled(&dp, disabled) && explicitly_enabled; let loaded = LoadedPlugin { name: name.clone(), id: dp.id, root: dp.root, canonical_root: dp.canonical_root, scope: dp.scope, origin: dp.origin, trusted: dp.trusted, enabled, version: dp.manifest.version, description: dp.manifest.description, skill_names, agent_names, skill_dirs: dp.skill_dirs, command_dirs: dp.command_dirs, agent_dirs: dp.agent_dirs, hooks_path: dp.hooks_path, mcp_config_path: dp.mcp_config_path.clone(), lsp_config_path: dp.lsp_config_path.clone(), skill_count, agent_count, has_hooks, hook_count, has_inline_hooks_only, mcp_server_count, has_inline_mcp_only, lsp_server_count, has_inline_lsp_only, inline_hooks, inline_mcp_servers, inline_lsp_servers, conflict: dp.conflict, }; // Track MCP server ownership for enabled + trusted plugins if loaded.enabled && loaded.trusted { for server_name in &mcp_server_names { mcp_owners .entry(server_name.clone()) .or_insert_with(|| name.clone()); } } plugins.insert(name, loaded); } Self { plugins, mcp_owners, session_plugin_dirs: Vec::new(), } } /// Create an empty registry. pub fn empty() -> Self { Self { plugins: HashMap::new(), mcp_owners: HashMap::new(), session_plugin_dirs: Vec::new(), } } /// Record the per-session dirs this registry was built with (see field docs). pub fn with_session_plugin_dirs(mut self, dirs: Vec) -> Self { self.session_plugin_dirs = dirs; self } /// Per-session dirs this registry was built with; empty for shared snapshots. pub fn session_plugin_dirs(&self) -> &[std::path::PathBuf] { &self.session_plugin_dirs } /// Get a plugin by name. pub fn get(&self, name: &str) -> Option<&LoadedPlugin> { self.plugins.get(name) } /// List all plugins (including disabled ones). pub fn list(&self) -> Vec<&LoadedPlugin> { let mut plugins: Vec<&LoadedPlugin> = self.plugins.values().collect(); plugins.sort_by(|a, b| { // Sort by scope (priority order), then by name (a.scope as u8, &a.name).cmp(&(b.scope as u8, &b.name)) }); plugins } /// List only enabled + trusted plugins (active for the session). pub fn active_plugins(&self) -> Vec<&LoadedPlugin> { self.list() .into_iter() .filter(|p| p.enabled && p.trusted) .collect() } /// List enabled plugins (both trusted and untrusted). /// Useful for skill/agent discovery where trust only gates executables. pub fn enabled_plugins(&self) -> Vec<&LoadedPlugin> { self.list().into_iter().filter(|p| p.enabled).collect() } /// Look up which plugin owns an MCP server by server name. pub fn mcp_server_owner(&self, server_name: &str) -> Option<&str> { self.mcp_owners.get(server_name).map(|s| s.as_str()) } /// Number of plugins in the registry. pub fn len(&self) -> usize { self.plugins.len() } /// Whether the registry is empty. pub fn is_empty(&self) -> bool { self.plugins.is_empty() } } // Shared handle for cross-thread reload /// Thread-safe handle for plugin registry lifecycle. /// /// Stores the process-lifetime CLI plugin dirs (which survive reload) /// and provides methods to build per-session registries and rebuild /// the shared "latest" registry for new sessions. #[derive(Debug, Clone)] pub struct SharedPluginRegistryHandle { /// The latest registry, rebuilt on `/plugins reload`. /// New sessions clone from here. Running sessions keep their snapshot. inner: std::sync::Arc>>>, /// CLI `--plugin-dir` paths from process startup. Preserved across reloads. cli_plugin_dirs: std::sync::Arc>, } impl SharedPluginRegistryHandle { /// Create a new handle with an initial registry and CLI plugin dirs. pub fn new(registry: Option, cli_plugin_dirs: Vec) -> Self { Self { inner: std::sync::Arc::new(std::sync::RwLock::new(registry.map(std::sync::Arc::new))), cli_plugin_dirs: std::sync::Arc::new(cli_plugin_dirs), } } /// Get the current registry snapshot (cheap Arc clone). pub fn snapshot(&self) -> Option> { self.inner.read().unwrap().clone() } /// Build a fresh registry for a specific session cwd. /// /// Pure registry construction (no disk mutation): also used by the read-only /// `commands/list` pull and the reload fan-out, so it must not refresh local /// installs. Use [`Self::refresh_and_build_for_cwd`] at genuine session spawn. /// CLI `--plugin-dir` paths from process startup are always included. /// /// `session_plugin_dirs` are per-session dirs from `session/new` / `session/load` /// `_meta.pluginDirs` — same CliOverride scope and trust as `--plugin-dir`, but /// only for the session whose registry this builds. /// /// `project_trusted` is the folder-trust verdict for `cwd`, threaded into /// discovery to gate Project-scope plugins. pub fn build_for_cwd( &self, cwd: &std::path::Path, disk_config: &super::discovery::DiscoveryConfig, session_plugin_dirs: &[std::path::PathBuf], project_trusted: bool, ) -> Option> { let mut config = disk_config.clone(); // Merge startup CLI dirs back in (they're not in config.toml) config .cli_plugin_dirs .extend(self.cli_plugin_dirs.iter().cloned()); config .cli_plugin_dirs .extend(session_plugin_dirs.iter().cloned()); let trust_store = super::trust::TrustStore::load(); let discovered = super::discovery::discover_plugins(Some(cwd), &config, &trust_store, project_trusted); if discovered.is_empty() { // Keep an empty registry alive when session dirs exist, so per-session // rebuilds can still recover them (see `session_plugin_dirs` field docs). (!session_plugin_dirs.is_empty()).then(|| { std::sync::Arc::new( PluginRegistry::empty().with_session_plugin_dirs(session_plugin_dirs.to_vec()), ) }) } else { config.populate_plugin_lists(&discovered); Some(std::sync::Arc::new( PluginRegistry::from_discovered(discovered, &config.disabled, &config.enabled) .with_session_plugin_dirs(session_plugin_dirs.to_vec()), )) } } /// Re-copy trusted / user-home local installs, then [`Self::build_for_cwd`]. /// /// The refresh is the session-spawn boundary for picking up agents/skills /// added to a live local source after install (the snapshot is a copy, not a /// symlink). Wired only to genuine session spawn — not the read-only /// `commands/list` pull or the reload fan-out, which use the pure builder. pub fn refresh_and_build_for_cwd( &self, cwd: &std::path::Path, disk_config: &super::discovery::DiscoveryConfig, session_plugin_dirs: &[std::path::PathBuf], project_trusted: bool, ) -> Option> { let trust_store = super::trust::TrustStore::load(); // Session spawn: cheap skip-unchanged (force=false). Self::run_local_refresh(&trust_store, false); self.build_for_cwd(cwd, disk_config, session_plugin_dirs, project_trusted) } /// Re-copy trusted local installs from disk and log the outcome. `force` /// bypasses the skip-unchanged guard: only the explicit `/plugins reload` /// passes `true`; session spawn and incidental rebuilds pass `false` (cheap /// structural skip). fn run_local_refresh(trust_store: &super::trust::TrustStore, force: bool) { let refresh = super::local_refresh::refresh_local_installs_from_disk(trust_store, force); tracing::debug!( refreshed = refresh.refreshed, skipped = refresh.skipped, errors = refresh.errors, force, "plugin registry local plugin refresh" ); } /// Rebuild the shared registry from disk and replace the shared state. /// /// `cwd` should be the session's working directory. /// `disk_config` should be freshly loaded from config.toml. /// CLI `--plugin-dir` paths from startup are automatically merged. /// /// Returns the count of plugins discovered. /// /// `project_trusted` is the folder-trust verdict for `cwd`, threaded into /// discovery to gate Project-scope plugins. /// /// `force` controls the local-install refresh: only the explicit, user-initiated /// `/plugins reload` passes `true` (guaranteed full re-copy — the manual remedy); /// incidental rebuilds (boot, plugin enable/disable/add/remove) pass `false` for /// the cheap structural skip-unchanged path. pub fn reload( &self, cwd: Option<&std::path::Path>, disk_config: &super::discovery::DiscoveryConfig, project_trusted: bool, force: bool, ) -> usize { let mut config = disk_config.clone(); config .cli_plugin_dirs .extend(self.cli_plugin_dirs.iter().cloned()); let trust_store = super::trust::TrustStore::load(); Self::run_local_refresh(&trust_store, force); let discovered = super::discovery::discover_plugins(cwd, &config, &trust_store, project_trusted); let count = discovered.len(); config.populate_plugin_lists(&discovered); let registry = PluginRegistry::from_discovered(discovered, &config.disabled, &config.enabled); *self.inner.write().unwrap() = if registry.is_empty() { None } else { Some(std::sync::Arc::new(registry)) }; count } } // Component counting helpers /// Collect the SKILL.md paths that load from the given skill dirs. /// /// Discovery via `find_skill_md_paths`; deduped by the loader's per-plugin /// identity — the normalized parent-dir basename (`stamp_plugin_fields` names /// plugin skills by directory basename, and `merge_skills_with_plugins` /// dedupes on `plugin:`) — so counts equal what actually loads, both /// for overlapping dir entries and same-basename dirs at different paths. pub fn skill_md_paths(skill_dirs: &[PathBuf]) -> Vec { use kigi_tools::implementations::skills::discovery::{ find_skill_md_paths, normalize_skill_name, }; let mut paths: Vec = skill_dirs .iter() .flat_map(|d| find_skill_md_paths(d)) .collect(); let mut seen = std::collections::HashSet::new(); paths.retain(|p| { p.parent() .and_then(|d| d.file_name()) .and_then(|n| n.to_str()) .map(|name| seen.insert(normalize_skill_name(name))) .unwrap_or(true) }); paths } /// Count skills under the skill dirs (each discovered SKILL.md = 1 skill). fn count_skill_subdirs(skill_dirs: &[PathBuf]) -> usize { skill_md_paths(skill_dirs).len() } fn count_md_files(dirs: &[PathBuf]) -> usize { dirs.iter() .flat_map(|dir| std::fs::read_dir(dir).ok()) .flatten() .filter_map(|e| e.ok()) .filter(|e| { e.file_type().map(|ft| ft.is_file()).unwrap_or(false) && e.path().extension().is_some_and(|ext| ext == "md") }) .count() } fn collect_md_names(dirs: &[PathBuf]) -> Vec { dirs.iter() .flat_map(|dir| std::fs::read_dir(dir).ok()) .flatten() .filter_map(|e| e.ok()) .filter(|e| { e.file_type().map(|ft| ft.is_file()).unwrap_or(false) && e.path().extension().is_some_and(|ext| ext == "md") }) .filter_map(|e| { e.path() .file_stem() .and_then(|s| s.to_str()) .map(String::from) }) .collect() } fn collect_skill_names(skill_dirs: &[PathBuf]) -> Vec { // Skill name = basename of the directory containing SKILL.md. skill_md_paths(skill_dirs) .iter() .filter_map(|p| p.parent()) .filter_map(|dir| dir.file_name().and_then(|n| n.to_str()).map(String::from)) .collect() } /// Deduped union of MCP server names from a plugin's `.mcp.json` file and its /// inline `mcpServers` manifest block (file names first, inline names appended). fn plugin_mcp_server_names(dp: &DiscoveredPlugin) -> Vec { let mut names: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); if let Some(ref path) = dp.mcp_config_path && let Ok(file_names) = read_mcp_server_names(path) { for server_name in file_names { if seen.insert(server_name.clone()) { names.push(server_name); } } } if let Some(inline) = dp.manifest.inline_mcp_servers() { let normalized = super::manifest::normalize_inline_mcp_servers(inline); if let Some(servers) = normalized.get("mcpServers").and_then(|v| v.as_object()) { for server_name in servers.keys() { if seen.insert(server_name.clone()) { names.push(server_name.clone()); } } } } names } fn count_lsp_servers(dp: &DiscoveredPlugin) -> usize { if let Some(ref path) = dp.lsp_config_path { std::fs::read_to_string(path) .ok() .and_then(|c| serde_json::from_str::(&c).ok()) .and_then(|v| v.as_object().map(|o| o.len())) .unwrap_or(0) } else if let Some(inline) = dp.manifest.inline_lsp_servers() { inline.as_object().map(|o| o.len()).unwrap_or(0) } else { 0 } } /// Count hook specs defined in a plugin's hooks.json and/or inline hooks. /// /// The hooks JSON structure is `{ "hooks": { "EventName": [ { "hooks": [...] } ] } }`. /// Each entry in the inner `hooks` array is one hook handler spec. fn count_hook_specs(hooks_path: Option<&Path>, inline_hooks: Option<&serde_json::Value>) -> usize { fn count_in_value(v: &serde_json::Value) -> usize { let Some(events) = v.get("hooks").and_then(|h| h.as_object()) else { return 0; }; events .values() .filter_map(|matchers| matchers.as_array()) .flat_map(|arr| arr.iter()) .filter_map(|group| group.get("hooks").and_then(|h| h.as_array())) .map(|hooks| hooks.len()) .sum() } let file_count = hooks_path .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|content| serde_json::from_str::(&content).ok()) .map(|v| count_in_value(&v)) .unwrap_or(0); let inline_count = inline_hooks.map(count_in_value).unwrap_or(0); file_count + inline_count } /// Read MCP server names from a .mcp.json file. fn read_mcp_server_names(path: &Path) -> Result, ()> { let content = std::fs::read_to_string(path).map_err(|_| ())?; let value: serde_json::Value = serde_json::from_str(&content).map_err(|_| ())?; let names = value .get("mcpServers") .and_then(|v| v.as_object()) .map(|obj| obj.keys().cloned().collect::>()) .unwrap_or_default(); Ok(names) } /// Check if a discovered plugin is in the disabled list. /// /// Matches by: /// 1. Full `plugin_id` (e.g. `"user/a1b2c3d4/my-plugin"`) /// 2. `plugin_name` shorthand (e.g. `"my-plugin"`) — only for convenience fn is_disabled(dp: &DiscoveredPlugin, disabled: &[String]) -> bool { disabled .iter() .any(|d| d == &dp.id.0 || d == &dp.manifest.name) } #[cfg(test)] mod tests { use super::super::discovery::PluginId; use super::super::manifest::PluginManifest; use super::*; fn make_discovered(name: &str, scope: PluginScope, trusted: bool) -> DiscoveredPlugin { let root = PathBuf::from(format!("/tmp/test-plugins/{name}")); DiscoveredPlugin { manifest: PluginManifest { name: name.to_string(), version: Some("1.0.0".to_string()), description: Some(format!("Test plugin {name}")), author: None, homepage: None, repository: None, license: None, keywords: vec![], skills: None, commands: None, agents: None, hooks: None, mcp_servers: None, lsp_servers: None, }, id: PluginId::new(scope, &root, name), root: root.clone(), canonical_root: root, scope, origin: match scope { PluginScope::CliOverride => PluginOrigin::CliOverride, PluginScope::Project => PluginOrigin::ProjectKigi, PluginScope::User => PluginOrigin::UserKigi, PluginScope::ConfigPath => PluginOrigin::ConfigPath, }, trusted, skill_dirs: vec![], command_dirs: vec![], agent_dirs: vec![], hooks_path: None, mcp_config_path: None, lsp_config_path: None, conflict: None, } } #[test] fn skill_counts_include_root_level_skill_md() { // Manifest `skills` entries pointing directly at skill dirs // (SKILL.md at the dir root) must be counted and named. let tmp = tempfile::tempdir().unwrap(); let one = tmp.path().join("one"); let two = tmp.path().join("two"); for (dir, name) in [(&one, "one"), (&two, "two")] { std::fs::create_dir_all(dir).unwrap(); std::fs::write( dir.join("SKILL.md"), format!("---\nname: {name}\ndescription: d\n---\nbody\n"), ) .unwrap(); } let dirs = vec![one, two]; assert_eq!(count_skill_subdirs(&dirs), 2); let mut names = collect_skill_names(&dirs); names.sort(); assert_eq!(names, vec!["one".to_string(), "two".to_string()]); } #[test] fn skill_counts_dedupe_same_basename_different_paths() { // Two skill dirs with the same basename at different paths collide on // the loader's per-plugin name identity — only one loads, so only one // may be counted. let tmp = tempfile::tempdir().unwrap(); for group in ["a", "b"] { let d = tmp.path().join(group).join("dup-skill"); std::fs::create_dir_all(&d).unwrap(); std::fs::write(d.join("SKILL.md"), "---\nname: dup\n---\n").unwrap(); } let dirs = vec![tmp.path().join("a"), tmp.path().join("b")]; assert_eq!(count_skill_subdirs(&dirs), 1); assert_eq!(collect_skill_names(&dirs), vec!["dup-skill".to_string()]); } #[test] fn skill_counts_dedupe_overlapping_dirs() { // Manifest lists both the parent skills dir and a child skill dir: // the child's SKILL.md is reachable via both entries but must count once. let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("skills"); let child = parent.join("one"); std::fs::create_dir_all(&child).unwrap(); std::fs::write(child.join("SKILL.md"), "---\nname: one\n---\n").unwrap(); let dirs = vec![parent, child]; assert_eq!(count_skill_subdirs(&dirs), 1); assert_eq!(collect_skill_names(&dirs), vec!["one".to_string()]); } #[test] fn skill_counts_convention_parent_dir() { // Convention layout: one parent dir with skill subdirectories. let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("skills"); for name in ["alpha", "beta"] { let d = parent.join(name); std::fs::create_dir_all(&d).unwrap(); std::fs::write(d.join("SKILL.md"), "---\nname: x\n---\n").unwrap(); } let dirs = vec![parent]; assert_eq!(count_skill_subdirs(&dirs), 2); let mut names = collect_skill_names(&dirs); names.sort(); assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]); } #[test] fn empty_registry() { let reg = PluginRegistry::empty(); assert!(reg.is_empty()); assert_eq!(reg.len(), 0); assert!(reg.list().is_empty()); } #[test] fn from_discovered_basic() { let plugins = vec![ make_discovered("alpha", PluginScope::User, true), make_discovered("beta", PluginScope::CliOverride, true), ]; let reg = PluginRegistry::from_discovered(plugins, &[], &[]); assert_eq!(reg.len(), 2); assert!(reg.get("alpha").is_some()); assert!(reg.get("beta").is_some()); assert!(reg.get("nonexistent").is_none()); } #[test] fn origin_threaded_to_loaded_plugin() { let mut dp = make_discovered("mp-tool", PluginScope::User, true); dp.origin = PluginOrigin::ClaudeMarketplace { marketplace: "demo-mp".to_string(), }; let reg = PluginRegistry::from_discovered(vec![dp], &[], &[]); assert_eq!( reg.get("mp-tool").unwrap().origin, PluginOrigin::ClaudeMarketplace { marketplace: "demo-mp".to_string(), } ); } #[test] fn disabled_plugins_filtered_from_active() { let plugins = vec![ make_discovered("enabled-plugin", PluginScope::User, true), make_discovered("disabled-plugin", PluginScope::User, true), ]; let reg = PluginRegistry::from_discovered( plugins, &["disabled-plugin".to_string()], &["enabled-plugin".to_string()], ); // Both in registry assert_eq!(reg.len(), 2); let active = reg.active_plugins(); // Only enabled one is active assert_eq!(active.len(), 1); assert_eq!(active[0].name, "enabled-plugin"); // Disabled one is in list but marked disabled let disabled = reg.get("disabled-plugin").unwrap(); assert!(!disabled.enabled); } #[test] fn disabled_by_plugin_id() { let dp = make_discovered("my-tool", PluginScope::User, true); let plugin_id = dp.id.0.clone(); let reg = PluginRegistry::from_discovered(vec![dp], &[plugin_id], &[]); assert!(!reg.get("my-tool").unwrap().enabled); } #[test] fn plugins_disabled_by_default() { // Both User and Project plugins default to disabled when not in enabled list. let plugins = vec![ make_discovered("user-one", PluginScope::User, true), make_discovered("project-one", PluginScope::Project, false), ]; let reg = PluginRegistry::from_discovered(plugins, &[], &[]); let active = reg.active_plugins(); assert_eq!(active.len(), 0); assert!(!reg.get("user-one").unwrap().enabled); assert!(!reg.get("project-one").unwrap().enabled); } #[test] fn cli_override_auto_enabled_via_populate() { // CliOverride plugins are added to the enabled list by populate_plugin_lists. use super::super::discovery::DiscoveryConfig; let plugins = vec![ make_discovered("cli-plugin", PluginScope::CliOverride, true), make_discovered("user-plugin", PluginScope::User, true), ]; let mut config = DiscoveryConfig::default(); config.populate_plugin_lists(&plugins); assert!(config.enabled.contains(&"cli-plugin".to_string())); assert!(config.disabled.contains(&"user-plugin".to_string())); let reg = PluginRegistry::from_discovered(plugins, &config.disabled, &config.enabled); assert!(reg.get("cli-plugin").unwrap().enabled); assert!(!reg.get("user-plugin").unwrap().enabled); } #[test] fn config_path_auto_enabled_via_populate() { // ConfigPath plugins are added to the enabled list by populate_plugin_lists. use super::super::discovery::DiscoveryConfig; let plugins = vec![ make_discovered("config-plugin", PluginScope::ConfigPath, true), make_discovered("user-plugin", PluginScope::User, true), ]; let mut config = DiscoveryConfig::default(); config.populate_plugin_lists(&plugins); assert!(config.enabled.contains(&"config-plugin".to_string())); assert!(config.disabled.contains(&"user-plugin".to_string())); let reg = PluginRegistry::from_discovered(plugins, &config.disabled, &config.enabled); assert!(reg.get("config-plugin").unwrap().enabled); assert!(!reg.get("user-plugin").unwrap().enabled); } #[test] fn list_sorted_by_scope_then_name() { let plugins = vec![ make_discovered("zebra", PluginScope::User, true), make_discovered("alpha", PluginScope::CliOverride, true), make_discovered("beta", PluginScope::Project, false), ]; let reg = PluginRegistry::from_discovered(plugins, &[], &[]); let list = reg.list(); // CliOverride = 0 assert_eq!(list[0].name, "alpha"); // Project = 1 assert_eq!(list[1].name, "beta"); // User = 2 assert_eq!(list[2].name, "zebra"); } #[test] fn data_dir_uses_plugin_id() { let dp = make_discovered("my-plugin", PluginScope::User, true); let reg = PluginRegistry::from_discovered(vec![dp], &[], &[]); let plugin = reg.get("my-plugin").unwrap(); let data_dir = plugin.data_dir(); // Should be under ~/.kigi/plugin-data// let data_dir_str = data_dir.to_string_lossy(); assert!(data_dir_str.contains("plugin-data")); assert!(data_dir_str.contains("user/")); assert!(data_dir_str.contains("/my-plugin")); } #[test] fn inline_hooks_populated_from_manifest() { use super::super::manifest::PathOrInline; let mut dp = make_discovered("hook-plugin", PluginScope::User, true); let inline_json = serde_json::json!({ "hooks": { "PostToolUse": [{"hooks": [{"type": "command", "command": "lint"}]}] } }); dp.manifest.hooks = Some(PathOrInline::Inline(inline_json.clone())); let reg = PluginRegistry::from_discovered(vec![dp], &[], &[]); let plugin = reg.get("hook-plugin").unwrap(); assert!(plugin.has_hooks); assert!(plugin.has_inline_hooks_only); assert!(plugin.hooks_path.is_none()); assert_eq!(plugin.inline_hooks.as_ref().unwrap(), &inline_json); } #[test] fn inline_mcp_populated_from_manifest() { use super::super::manifest::PathOrInline; let mut dp = make_discovered("mcp-plugin", PluginScope::User, true); let inline_json = serde_json::json!({ "mcpServers": { "my-server": {"command": "./server", "args": []} } }); dp.manifest.mcp_servers = Some(PathOrInline::Inline(inline_json.clone())); let reg = PluginRegistry::from_discovered(vec![dp], &[], &["mcp-plugin".to_string()]); let plugin = reg.get("mcp-plugin").unwrap(); assert!(plugin.has_inline_mcp_only); assert!(plugin.mcp_config_path.is_none()); assert_eq!(plugin.inline_mcp_servers.as_ref().unwrap(), &inline_json); // Should track ownership from inline (plugin is enabled) assert_eq!(reg.mcp_server_owner("my-server"), Some("mcp-plugin")); } // Combined disabled + untrusted scenarios #[test] fn disabled_project_plugin_excluded_from_active_and_enabled() { let plugins = vec![ make_discovered("good-plugin", PluginScope::User, true), make_discovered("bad-plugin", PluginScope::Project, false), ]; let reg = PluginRegistry::from_discovered( plugins, &["bad-plugin".to_string()], &["good-plugin".to_string()], ); assert_eq!(reg.len(), 2); let active = reg.active_plugins(); assert_eq!(active.len(), 1); assert_eq!(active[0].name, "good-plugin"); let enabled = reg.enabled_plugins(); assert_eq!(enabled.len(), 1); assert_eq!(enabled[0].name, "good-plugin"); let bad = reg.get("bad-plugin").unwrap(); assert!(!bad.enabled); // trusted is propagated from discovery (was false for Project scope) assert!(!bad.trusted); } #[test] fn unlisted_plugins_disabled_by_default() { // Plugins not in either enabled or disabled list default to disabled. let plugins = vec![ make_discovered("listed", PluginScope::User, true), make_discovered("unlisted", PluginScope::User, true), ]; let reg = PluginRegistry::from_discovered(plugins, &[], &["listed".to_string()]); let enabled = reg.enabled_plugins(); assert_eq!(enabled.len(), 1); assert_eq!(enabled[0].name, "listed"); assert!(!reg.get("unlisted").unwrap().enabled); } #[test] fn enabled_plugins_excludes_disabled() { // enabled_plugins() must NOT include disabled plugins, even if trusted. let plugins = vec![ make_discovered("enabled-trusted", PluginScope::User, true), make_discovered("disabled-trusted", PluginScope::User, true), ]; let reg = PluginRegistry::from_discovered( plugins, &["disabled-trusted".to_string()], &["enabled-trusted".to_string()], ); let enabled = reg.enabled_plugins(); assert_eq!(enabled.len(), 1); assert_eq!(enabled[0].name, "enabled-trusted"); } #[test] fn is_disabled_matches_by_name() { let dp = make_discovered("my-tool", PluginScope::User, true); assert!(is_disabled(&dp, &["my-tool".to_string()])); } #[test] fn is_disabled_matches_by_id() { let dp = make_discovered("my-tool", PluginScope::User, true); let id = dp.id.0.clone(); assert!(is_disabled(&dp, &[id])); } #[test] fn is_disabled_no_match() { let dp = make_discovered("my-tool", PluginScope::User, true); assert!(!is_disabled(&dp, &["other-tool".to_string()])); } #[test] fn is_disabled_empty_list() { let dp = make_discovered("my-tool", PluginScope::User, true); assert!(!is_disabled(&dp, &[])); } #[test] fn multiple_disabled_plugins() { let plugins = vec![ make_discovered("alpha", PluginScope::User, true), make_discovered("beta", PluginScope::User, true), make_discovered("gamma", PluginScope::User, true), ]; let reg = PluginRegistry::from_discovered( plugins, &["alpha".to_string(), "gamma".to_string()], &["beta".to_string()], ); assert_eq!(reg.len(), 3); assert!(!reg.get("alpha").unwrap().enabled); assert!(reg.get("beta").unwrap().enabled); assert!(!reg.get("gamma").unwrap().enabled); let active = reg.active_plugins(); assert_eq!(active.len(), 1); assert_eq!(active[0].name, "beta"); } #[test] fn mcp_deduped_when_declared_in_file_and_inline() { use super::super::manifest::PathOrInline; let tmp = tempfile::tempdir().unwrap(); let mcp_json = tmp.path().join(".mcp.json"); std::fs::write( &mcp_json, r#"{"mcpServers":{"sentry":{"type":"http","url":"https://mcp.sentry.dev/mcp"}}}"#, ) .unwrap(); let mut dp = make_discovered("sentry", PluginScope::User, true); dp.mcp_config_path = Some(mcp_json); dp.manifest.mcp_servers = Some(PathOrInline::Inline(serde_json::json!({ "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } }))); let reg = PluginRegistry::from_discovered(vec![dp], &[], &["sentry".to_string()]); let plugin = reg.get("sentry").unwrap(); assert_eq!( plugin.mcp_server_count, 1, "same server in both .mcp.json and inline must dedupe to 1" ); assert_eq!(reg.mcp_server_owner("sentry"), Some("sentry")); } #[test] fn mcp_count_from_inline_direct_map() { use super::super::manifest::PathOrInline; let mut dp = make_discovered("sentry", PluginScope::User, true); dp.manifest.mcp_servers = Some(PathOrInline::Inline(serde_json::json!({ "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } }))); let reg = PluginRegistry::from_discovered(vec![dp], &[], &["sentry".to_string()]); let plugin = reg.get("sentry").unwrap(); assert_eq!(plugin.mcp_server_count, 1); assert_eq!(reg.mcp_server_owner("sentry"), Some("sentry")); } #[test] fn mcp_ownership_not_tracked_for_disabled() { use super::super::manifest::PathOrInline; let mut dp = make_discovered("disabled-mcp", PluginScope::User, true); dp.manifest.mcp_servers = Some(PathOrInline::Inline(serde_json::json!({ "mcpServers": { "my-server": {"command": "./server"} } }))); let reg = PluginRegistry::from_discovered(vec![dp], &["disabled-mcp".to_string()], &[]); // Disabled plugins should NOT have MCP ownership tracked assert_eq!(reg.mcp_server_owner("my-server"), None); } #[test] fn inline_mcp_ownership_not_tracked_for_untrusted() { use super::super::manifest::PathOrInline; let mut dp = make_discovered("untrusted-mcp", PluginScope::Project, false); dp.manifest.mcp_servers = Some(PathOrInline::Inline(serde_json::json!({ "mcpServers": { "blocked-server": {"command": "./server"} } }))); let reg = PluginRegistry::from_discovered(vec![dp], &[], &[]); // Untrusted plugins should NOT have MCP ownership tracked assert_eq!(reg.mcp_server_owner("blocked-server"), None); } #[test] fn populate_plugin_lists_exhaustive() { use super::super::discovery::DiscoveryConfig; let plugins = vec![ make_discovered("cli", PluginScope::CliOverride, true), make_discovered("user", PluginScope::User, true), make_discovered("project", PluginScope::Project, false), make_discovered("config", PluginScope::ConfigPath, true), ]; let mut config = DiscoveryConfig::default(); config.populate_plugin_lists(&plugins); // CliOverride and ConfigPath → enabled; User and Project → disabled assert!(config.enabled.contains(&"cli".to_string())); assert!(config.enabled.contains(&"config".to_string())); assert!(config.disabled.contains(&"user".to_string())); assert!(config.disabled.contains(&"project".to_string())); // Every plugin accounted for assert_eq!(config.enabled.len() + config.disabled.len(), 4); } #[test] fn populate_plugin_lists_preserves_existing() { use super::super::discovery::DiscoveryConfig; let plugins = vec![ make_discovered("already-enabled", PluginScope::User, true), make_discovered("already-disabled", PluginScope::User, true), make_discovered("new-user", PluginScope::User, true), ]; let mut config = DiscoveryConfig { enabled: vec!["already-enabled".to_string()], disabled: vec!["already-disabled".to_string()], ..Default::default() }; config.populate_plugin_lists(&plugins); // Pre-existing entries untouched assert!(config.enabled.contains(&"already-enabled".to_string())); assert!(config.disabled.contains(&"already-disabled".to_string())); // New user plugin defaults to disabled assert!(config.disabled.contains(&"new-user".to_string())); assert_eq!(config.enabled.len(), 1); assert_eq!(config.disabled.len(), 2); } // Security: trust propagation from discovery #[test] fn untrusted_project_plugin_excluded_from_active_even_when_enabled() { // Simulates the attack: a project plugin is enabled (e.g. via // pre-populated enabledPlugins) but NOT trusted. It must NOT // appear in active_plugins() so its hooks never fire. let plugins = vec![ // untrusted make_discovered("malicious", PluginScope::Project, false), ]; let reg = PluginRegistry::from_discovered( plugins, &[], // attacker got it into enabled list &["malicious".to_string()], ); // Plugin is enabled but not trusted let plugin = reg.get("malicious").unwrap(); assert!(plugin.enabled); assert!(!plugin.trusted); // Must NOT appear in active_plugins (hooks would fire) let active = reg.active_plugins(); assert!( active.is_empty(), "untrusted project plugin must not be in active_plugins" ); } #[test] fn trusted_field_propagated_from_discovery() { // Verify that from_discovered preserves the trust value from // discovery rather than hardcoding it to true. let trusted_plugin = make_discovered("user-tool", PluginScope::User, true); let untrusted_plugin = make_discovered("project-tool", PluginScope::Project, false); let reg = PluginRegistry::from_discovered( vec![trusted_plugin, untrusted_plugin], &[], &["user-tool".to_string(), "project-tool".to_string()], ); assert!(reg.get("user-tool").unwrap().trusted); assert!(!reg.get("project-tool").unwrap().trusted); } #[test] fn pre_enabled_project_plugin_blocked_without_trust() { // End-to-end: populate_plugin_lists won't auto-disable a plugin // that's already in the enabled list, but active_plugins() must // still block it when untrusted. use super::super::discovery::DiscoveryConfig; let plugins = vec![make_discovered( "attacker-plugin", PluginScope::Project, false, )]; let mut config = DiscoveryConfig { // Attacker pre-populated the enabled list (via .claude/settings.json) enabled: vec!["attacker-plugin".to_string()], ..Default::default() }; config.populate_plugin_lists(&plugins); // populate_plugin_lists skips it because it's already listed assert!(config.enabled.contains(&"attacker-plugin".to_string())); let reg = PluginRegistry::from_discovered(plugins, &config.disabled, &config.enabled); let plugin = reg.get("attacker-plugin").unwrap(); assert!(plugin.enabled, "plugin is in enabled list"); assert!(!plugin.trusted, "project plugin is not trusted"); assert!( reg.active_plugins().is_empty(), "untrusted plugin must not appear in active_plugins" ); } }