Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1253 lines
47 KiB
Rust
1253 lines
47 KiB
Rust
//! 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<String>,
|
|
/// Plugin description from manifest.
|
|
pub description: Option<String>,
|
|
/// Resolved skill directories.
|
|
pub skill_dirs: Vec<PathBuf>,
|
|
pub command_dirs: Vec<PathBuf>,
|
|
/// Resolved agent directories.
|
|
pub agent_dirs: Vec<PathBuf>,
|
|
/// Resolved hooks file path.
|
|
pub hooks_path: Option<PathBuf>,
|
|
/// Resolved MCP config file path.
|
|
pub mcp_config_path: Option<PathBuf>,
|
|
/// Resolved LSP config file path.
|
|
pub lsp_config_path: Option<PathBuf>,
|
|
/// 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<String>,
|
|
/// Agent/persona names (filenames without .md extension).
|
|
pub agent_names: Vec<String>,
|
|
/// 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<serde_json::Value>,
|
|
/// Inline MCP servers JSON from manifest (when defined inline, not file-based).
|
|
pub inline_mcp_servers: Option<serde_json::Value>,
|
|
/// Inline LSP servers JSON from manifest (when defined inline, not file-based).
|
|
pub inline_lsp_servers: Option<serde_json::Value>,
|
|
/// Warning if this plugin won a name collision with another plugin.
|
|
pub conflict: Option<String>,
|
|
}
|
|
|
|
impl LoadedPlugin {
|
|
/// Data directory for this plugin: `~/.kigi/plugin-data/<plugin_id>/`.
|
|
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<String, LoadedPlugin>,
|
|
/// Map from MCP server name to the plugin that owns it.
|
|
mcp_owners: HashMap<String, String>,
|
|
/// 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<std::path::PathBuf>,
|
|
}
|
|
|
|
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<DiscoveredPlugin>,
|
|
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<std::path::PathBuf>) -> 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<std::sync::RwLock<Option<std::sync::Arc<PluginRegistry>>>>,
|
|
/// CLI `--plugin-dir` paths from process startup. Preserved across reloads.
|
|
cli_plugin_dirs: std::sync::Arc<Vec<std::path::PathBuf>>,
|
|
}
|
|
|
|
impl SharedPluginRegistryHandle {
|
|
/// Create a new handle with an initial registry and CLI plugin dirs.
|
|
pub fn new(registry: Option<PluginRegistry>, cli_plugin_dirs: Vec<std::path::PathBuf>) -> 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<std::sync::Arc<PluginRegistry>> {
|
|
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<std::sync::Arc<PluginRegistry>> {
|
|
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<std::sync::Arc<PluginRegistry>> {
|
|
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:<name>`) — 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<PathBuf> {
|
|
use kigi_tools::implementations::skills::discovery::{
|
|
find_skill_md_paths, normalize_skill_name,
|
|
};
|
|
|
|
let mut paths: Vec<PathBuf> = 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<String> {
|
|
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<String> {
|
|
// 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<String> {
|
|
let mut names: Vec<String> = Vec::new();
|
|
let mut seen: std::collections::HashSet<String> = 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::<serde_json::Value>(&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::<serde_json::Value>(&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<Vec<String>, ()> {
|
|
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::<Vec<_>>())
|
|
.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/<plugin_id>/
|
|
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"
|
|
);
|
|
}
|
|
}
|