use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::error::{PluginError, PluginResult};
use crate::manifest::{
EventSinkCapabilityState, EventSinkManifestEntry, ObservationPermissionId, Platform,
PluginManifest,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginSource {
LocalDir { path: PathBuf },
LocalArchive { path: PathBuf },
Url {
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
allow_unverified: bool,
#[serde(default, skip_serializing_if = "is_false")]
allow_untrusted_host: bool,
#[serde(default, skip_serializing_if = "is_false")]
allow_unsigned: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
signed_by: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
insecure: bool,
},
}
fn is_false(value: &bool) -> bool {
!*value
}
pub type EventSinkPermissionGrants = BTreeMap<String, Vec<ObservationPermissionId>>;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisteredCapabilities {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_server_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skill_dirs: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preset_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workflow_filenames: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub service_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub event_sink_ids: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub event_sink_grants: EventSinkPermissionGrants,
}
impl RegisteredCapabilities {
pub fn is_empty(&self) -> bool {
self.mcp_server_ids.is_empty()
&& self.skill_dirs.is_empty()
&& self.preset_ids.is_empty()
&& self.workflow_filenames.is_empty()
&& self.service_ids.is_empty()
&& self.event_sink_ids.is_empty()
}
pub fn removed_since(&self, old: &RegisteredCapabilities) -> RegisteredCapabilities {
RegisteredCapabilities {
mcp_server_ids: subtract(&old.mcp_server_ids, &self.mcp_server_ids),
skill_dirs: subtract(&old.skill_dirs, &self.skill_dirs),
preset_ids: subtract(&old.preset_ids, &self.preset_ids),
workflow_filenames: subtract(&old.workflow_filenames, &self.workflow_filenames),
service_ids: subtract(&old.service_ids, &self.service_ids),
event_sink_ids: subtract(&old.event_sink_ids, &self.event_sink_ids),
event_sink_grants: BTreeMap::new(),
}
}
pub fn removal_order(&self) -> EventSinkRemovalOrder {
EventSinkRemovalOrder {
event_sink_ids_before_services: self.event_sink_ids.clone(),
service_ids_after_sinks: self.service_ids.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EventSinkRemovalOrder {
pub event_sink_ids_before_services: Vec<String>,
pub service_ids_after_sinks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconciledEventSink {
pub id: String,
pub service_id: String,
pub state: EventSinkCapabilityState,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EventSinkReconciliation {
pub deactivate_before_services: Vec<String>,
pub service_dependencies_before_sinks: Vec<String>,
pub sinks_after_services: Vec<ReconciledEventSink>,
}
#[derive(Debug, Clone)]
pub struct PluginBootCandidate {
pub installed: InstalledPlugin,
pub manifest: Option<PluginManifest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginBootIssue {
DuplicatePluginId { id: String },
ManifestUnavailable,
ManifestIdMismatch { manifest_id: String },
InvalidManifest { detail: String },
InstallIncomplete,
UnknownPlatform,
PlatformIneligible,
DuplicateEventSinkOwner { id: String },
DuplicateServiceOwner { id: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginBootReconciliation {
pub plugin_id: String,
pub service_ids_to_start: Vec<String>,
pub event_sinks: EventSinkReconciliation,
pub issues: Vec<PluginBootIssue>,
}
pub fn reconcile_plugin_boot(
candidates: &[PluginBootCandidate],
platform: Option<Platform>,
) -> Vec<PluginBootReconciliation> {
let mut plugin_id_counts: HashMap<&str, usize> = HashMap::new();
let mut sink_owner_counts: HashMap<&str, usize> = HashMap::new();
let mut service_owner_counts: HashMap<&str, usize> = HashMap::new();
for candidate in candidates {
*plugin_id_counts
.entry(candidate.installed.id.as_str())
.or_default() += 1;
for id in &candidate.installed.registered.event_sink_ids {
*sink_owner_counts.entry(id.as_str()).or_default() += 1;
}
for id in &candidate.installed.registered.service_ids {
*service_owner_counts.entry(id.as_str()).or_default() += 1;
}
}
candidates
.iter()
.map(|candidate| {
let installed = &candidate.installed;
let mut plan = PluginBootReconciliation {
plugin_id: installed.id.clone(),
event_sinks: EventSinkReconciliation {
deactivate_before_services: unique_strings(
&installed.registered.event_sink_ids,
),
..Default::default()
},
..Default::default()
};
if plugin_id_counts
.get(installed.id.as_str())
.copied()
.unwrap_or_default()
> 1
{
plan.issues.push(PluginBootIssue::DuplicatePluginId {
id: installed.id.clone(),
});
return plan;
}
if installed.status == PluginInstallStatus::Installing {
plan.issues.push(PluginBootIssue::InstallIncomplete);
return plan;
}
let Some(platform) = platform else {
plan.issues.push(PluginBootIssue::UnknownPlatform);
return plan;
};
let Some(manifest) = candidate.manifest.as_ref() else {
plan.issues.push(PluginBootIssue::ManifestUnavailable);
return plan;
};
if manifest.id != installed.id {
plan.issues.push(PluginBootIssue::ManifestIdMismatch {
manifest_id: manifest.id.clone(),
});
return plan;
}
if let Err(error) = manifest.validate() {
plan.issues.push(PluginBootIssue::InvalidManifest {
detail: error.to_string(),
});
return plan;
}
if !manifest.supports_platform(platform) {
plan.issues.push(PluginBootIssue::PlatformIneligible);
return plan;
}
let mut sink_plan = reconcile_event_sinks(
manifest,
&installed.registered,
installed.status,
Some(platform),
)
.expect("manifest was validated above");
let mut unsafe_sink_ids = HashSet::new();
let mut unsafe_backing_services = HashSet::new();
for sink_id in &installed.registered.event_sink_ids {
if sink_owner_counts
.get(sink_id.as_str())
.copied()
.unwrap_or_default()
> 1
{
push_issue_once(
&mut plan.issues,
PluginBootIssue::DuplicateEventSinkOwner {
id: sink_id.clone(),
},
);
unsafe_sink_ids.insert(sink_id.as_str());
if let Some(sink) = manifest
.provides
.event_sinks
.iter()
.find(|sink| sink.id == *sink_id)
{
unsafe_backing_services.insert(sink.service_id.as_str());
}
}
}
for service_id in &installed.registered.service_ids {
if service_owner_counts
.get(service_id.as_str())
.copied()
.unwrap_or_default()
> 1
{
push_issue_once(
&mut plan.issues,
PluginBootIssue::DuplicateServiceOwner {
id: service_id.clone(),
},
);
unsafe_backing_services.insert(service_id.as_str());
}
}
for sink in &manifest.provides.event_sinks {
if installed
.registered
.event_sink_ids
.iter()
.any(|id| id == &sink.id)
&& service_owner_counts
.get(sink.service_id.as_str())
.copied()
.unwrap_or_default()
!= 1
{
unsafe_sink_ids.insert(sink.id.as_str());
unsafe_backing_services.insert(sink.service_id.as_str());
}
}
sink_plan.sinks_after_services.retain(|sink| {
!unsafe_sink_ids.contains(sink.id.as_str())
&& !unsafe_backing_services.contains(sink.service_id.as_str())
});
sink_plan
.service_dependencies_before_sinks
.retain(|service_id| !unsafe_backing_services.contains(service_id.as_str()));
for sink_id in unsafe_sink_ids {
if !sink_plan
.deactivate_before_services
.iter()
.any(|id| id == sink_id)
{
sink_plan
.deactivate_before_services
.push(sink_id.to_string());
}
}
let owned_services: HashSet<&str> = installed
.registered
.service_ids
.iter()
.map(String::as_str)
.collect();
plan.service_ids_to_start = manifest
.provides
.services
.iter()
.filter(|service| {
service.enabled
&& owned_services.contains(service.id.as_str())
&& service_owner_counts
.get(service.id.as_str())
.copied()
.unwrap_or_default()
== 1
&& !unsafe_backing_services.contains(service.id.as_str())
})
.map(|service| service.id.clone())
.collect();
plan.event_sinks = sink_plan;
plan
})
.collect()
}
fn unique_strings(values: &[String]) -> Vec<String> {
let mut seen = HashSet::new();
values
.iter()
.filter(|value| seen.insert(value.as_str()))
.cloned()
.collect()
}
fn push_issue_once(issues: &mut Vec<PluginBootIssue>, issue: PluginBootIssue) {
if !issues.contains(&issue) {
issues.push(issue);
}
}
pub fn reconcile_event_sinks(
manifest: &PluginManifest,
registered: &RegisteredCapabilities,
install_status: PluginInstallStatus,
platform: Option<Platform>,
) -> PluginResult<EventSinkReconciliation> {
manifest.validate()?;
let plugin_platform_eligible =
platform.is_some_and(|platform| manifest.supports_platform(platform));
let owned_sink_ids: HashSet<&str> = registered
.event_sink_ids
.iter()
.map(String::as_str)
.collect();
let owned_service_ids: HashSet<&str> =
registered.service_ids.iter().map(String::as_str).collect();
let mut plan = EventSinkReconciliation::default();
let mut reconciled_ids = HashSet::new();
let mut service_dependencies = HashSet::new();
for sink in &manifest.provides.event_sinks {
if !owned_sink_ids.contains(sink.id.as_str()) {
continue;
}
let Some(service) =
same_plugin_owned_service(sink, &manifest.provides.services, &owned_service_ids)
else {
if reconciled_ids.insert(sink.id.as_str()) {
plan.deactivate_before_services.push(sink.id.clone());
}
continue;
};
if !reconciled_ids.insert(sink.id.as_str()) {
continue;
}
let state = if install_status == PluginInstallStatus::Installing {
EventSinkCapabilityState::Inactive {
detail: crate::manifest::EventSinkInactiveReason::InstallIncomplete,
}
} else if !plugin_platform_eligible {
EventSinkCapabilityState::Inactive {
detail: crate::manifest::EventSinkInactiveReason::PlatformIneligible,
}
} else {
sink.capability_state(service, platform)
};
if matches!(state, EventSinkCapabilityState::Eligible)
&& service_dependencies.insert(service.id.as_str())
{
plan.service_dependencies_before_sinks
.push(service.id.clone());
}
plan.sinks_after_services.push(ReconciledEventSink {
id: sink.id.clone(),
service_id: service.id.clone(),
state,
});
}
for owned_id in ®istered.event_sink_ids {
if !reconciled_ids.contains(owned_id.as_str())
&& !plan.deactivate_before_services.contains(owned_id)
{
plan.deactivate_before_services.push(owned_id.clone());
}
}
Ok(plan)
}
fn same_plugin_owned_service<'a>(
sink: &EventSinkManifestEntry,
services: &'a [crate::manifest::ServiceManifestEntry],
owned_service_ids: &HashSet<&str>,
) -> Option<&'a crate::manifest::ServiceManifestEntry> {
if !owned_service_ids.contains(sink.service_id.as_str()) {
return None;
}
services
.iter()
.find(|service| service.id == sink.service_id)
}
fn subtract(from: &[String], remove: &[String]) -> Vec<String> {
let drop: HashSet<&str> = remove.iter().map(String::as_str).collect();
from.iter()
.filter(|value| !drop.contains(value.as_str()))
.cloned()
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ownership {
New,
OwnedReinstall,
ForeignConflict,
}
pub fn classify_ownership(
id: &str,
existing: &HashSet<&str>,
owned_previously: &HashSet<&str>,
) -> Ownership {
if !existing.contains(id) {
Ownership::New
} else if owned_previously.contains(id) {
Ownership::OwnedReinstall
} else {
Ownership::ForeignConflict
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExclusiveReconciliation {
pub to_register: Vec<String>,
pub foreign_conflicts: Vec<String>,
}
pub fn reconcile_exclusive(
declared: &[String],
existing: &[String],
owned_previously: &[String],
) -> ExclusiveReconciliation {
let existing_set: HashSet<&str> = existing.iter().map(String::as_str).collect();
let owned_set: HashSet<&str> = owned_previously.iter().map(String::as_str).collect();
let mut result = ExclusiveReconciliation::default();
for id in declared {
match classify_ownership(id, &existing_set, &owned_set) {
Ownership::New | Ownership::OwnedReinstall => result.to_register.push(id.clone()),
Ownership::ForeignConflict => result.foreign_conflicts.push(id.clone()),
}
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginInstallStatus {
Installing,
#[default]
Installed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledPlugin {
pub id: String,
pub version: String,
pub source: PluginSource,
pub plugin_dir: PathBuf,
pub installed_at: DateTime<Utc>,
#[serde(default)]
pub status: PluginInstallStatus,
#[serde(default)]
pub registered: RegisteredCapabilities,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstalledPlugins {
#[serde(default)]
pub plugins: Vec<InstalledPlugin>,
}
impl InstalledPlugins {
pub async fn load(path: &Path) -> PluginResult<Self> {
match fs::try_exists(path).await {
Ok(true) => {}
Ok(false) => return Ok(Self::default()),
Err(error) => return Err(PluginError::Io(error)),
}
let raw = fs::read_to_string(path).await?;
if raw.trim().is_empty() {
return Ok(Self::default());
}
let store: Self = serde_json::from_str(&raw)?;
Ok(store)
}
pub async fn save(&self, path: &Path) -> PluginResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let serialized = serde_json::to_string_pretty(self)?;
let tmp_path = tmp_path_for(path);
fs::write(&tmp_path, serialized).await?;
fs::rename(&tmp_path, path).await?;
Ok(())
}
pub fn get(&self, id: &str) -> Option<&InstalledPlugin> {
self.plugins.iter().find(|plugin| plugin.id == id)
}
pub fn get_unique(&self, id: &str) -> PluginResult<Option<&InstalledPlugin>> {
let mut matches = self.plugins.iter().filter(|plugin| plugin.id == id);
let first = matches.next();
if matches.next().is_some() {
return Err(PluginError::Registration(format!(
"installed plugin registry contains duplicate rows for id '{id}'"
)));
}
Ok(first)
}
pub fn add(&mut self, plugin: InstalledPlugin) {
self.remove(&plugin.id);
self.plugins.push(plugin);
}
pub fn remove(&mut self, id: &str) -> Option<InstalledPlugin> {
let index = self.plugins.iter().position(|plugin| plugin.id == id)?;
Some(self.plugins.remove(index))
}
pub fn list(&self) -> &[InstalledPlugin] {
&self.plugins
}
}
fn tmp_path_for(path: &Path) -> PathBuf {
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(".tmp");
path.with_file_name(tmp_name)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_plugin(id: &str) -> InstalledPlugin {
InstalledPlugin {
id: id.to_string(),
version: "0.1.0".to_string(),
source: PluginSource::LocalDir {
path: PathBuf::from("/tmp/source"),
},
plugin_dir: PathBuf::from(format!("/home/user/.bamboo/plugins/{id}")),
installed_at: DateTime::parse_from_rfc3339("2026-07-12T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
status: PluginInstallStatus::Installed,
registered: RegisteredCapabilities {
mcp_server_ids: vec![],
skill_dirs: vec!["hello-world".to_string()],
preset_ids: vec!["hello_preset".to_string()],
workflow_filenames: vec![],
service_ids: vec![],
event_sink_ids: vec![],
event_sink_grants: BTreeMap::new(),
},
}
}
fn event_sink_manifest(service_enabled: bool, protocol_version: u16) -> PluginManifest {
let json = serde_json::json!({
"id": "event-plugin",
"name": "Event Plugin",
"version": "1.0.0",
"provides": {
"services": [{
"id": "audit-service",
"enabled": service_enabled,
"command": "${platform_bin}",
"input_protocol": "ndjson_v1"
}],
"event_sinks": [{
"id": "audit-events",
"service_id": "audit-service",
"protocol": {"name": "tool_event", "version": protocol_version},
"subscriptions": [{"id": "tool.file_changed.v1"}],
"requested_permissions": ["metadata"]
}]
}
});
let manifest = PluginManifest::parse_str(&json.to_string()).expect("parse sink manifest");
manifest.validate().expect("validate sink manifest");
manifest
}
#[tokio::test]
async fn load_missing_file_returns_empty_registry() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("plugins").join("installed.json");
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert!(loaded.plugins.is_empty());
}
#[tokio::test]
async fn save_is_atomic_via_tmp_file_rename() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("installed.json");
let tmp_path = tmp_path_for(&path);
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
store.save(&path).await.expect("save");
assert!(path.exists(), "installed.json should exist after save");
assert!(
!tmp_path.exists(),
"the .tmp staging file must be renamed over the target, never left behind"
);
let mut reloaded = InstalledPlugins::load(&path).await.expect("load");
reloaded.add(sample_plugin("other-plugin"));
reloaded.save(&path).await.expect("save again");
assert!(!tmp_path.exists());
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert_eq!(loaded.plugins.len(), 2);
}
#[tokio::test]
async fn save_then_load_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("plugins").join("installed.json");
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
store.add(sample_plugin("other-plugin"));
store.save(&path).await.expect("save");
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert_eq!(loaded.plugins.len(), 2);
let hello = loaded.get("hello-plugin").expect("hello-plugin present");
assert_eq!(hello.version, "0.1.0");
assert_eq!(hello.registered.skill_dirs, vec!["hello-world".to_string()]);
assert_eq!(
hello.registered.preset_ids,
vec!["hello_preset".to_string()]
);
assert_eq!(
hello.source,
PluginSource::LocalDir {
path: PathBuf::from("/tmp/source")
}
);
}
#[test]
fn legacy_provenance_defaults_and_omits_event_sink_ids() {
let raw = r#"{
"plugins": [{
"id": "legacy-plugin",
"version": "1.0.0",
"source": {"type": "local_dir", "path": "/tmp/legacy"},
"plugin_dir": "/tmp/legacy",
"installed_at": "2026-07-12T00:00:00Z",
"registered": {"service_ids": ["legacy-service"]}
}]
}"#;
let store: InstalledPlugins = serde_json::from_str(raw).expect("load legacy provenance");
assert!(store.plugins[0].registered.event_sink_ids.is_empty());
let serialized = serde_json::to_value(&store).expect("serialize provenance");
assert!(serialized["plugins"][0]["registered"]
.get("event_sink_ids")
.is_none());
assert!(!serde_json::to_string(&store)
.expect("serialize legacy provenance bytes")
.contains("event_sink_ids"));
}
#[tokio::test]
async fn add_upserts_by_id() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("installed.json");
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
let mut upgraded = sample_plugin("hello-plugin");
upgraded.version = "0.2.0".to_string();
store.add(upgraded);
assert_eq!(store.plugins.len(), 1);
assert_eq!(store.get("hello-plugin").unwrap().version, "0.2.0");
store.save(&path).await.expect("save");
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert_eq!(loaded.plugins.len(), 1);
assert_eq!(loaded.get("hello-plugin").unwrap().version, "0.2.0");
}
#[test]
fn unique_lookup_rejects_duplicate_plugin_rows() {
let mut store = InstalledPlugins::default();
store.plugins.push(sample_plugin("hello-plugin"));
let mut duplicate = sample_plugin("hello-plugin");
duplicate.plugin_dir = PathBuf::from("/tmp/duplicate-plugin-dir");
store.plugins.push(duplicate);
let error = store
.get_unique("hello-plugin")
.expect_err("duplicate identity must be ambiguous");
assert!(matches!(error, PluginError::Registration(_)));
assert!(error.to_string().contains("duplicate rows"));
assert!(store.get_unique("missing-plugin").unwrap().is_none());
}
#[tokio::test]
async fn remove_deletes_and_returns_entry() {
let mut store = InstalledPlugins::default();
store.add(sample_plugin("hello-plugin"));
let removed = store.remove("hello-plugin").expect("present before remove");
assert_eq!(removed.id, "hello-plugin");
assert!(store.get("hello-plugin").is_none());
assert!(store.remove("hello-plugin").is_none());
}
#[test]
fn reconcile_exclusive_fresh_install_splits_new_from_foreign() {
let declared = vec!["a".to_string(), "b".to_string()];
let existing = vec!["b".to_string(), "user-thing".to_string()];
let owned_previously: Vec<String> = vec![];
let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
assert_eq!(reconciliation.to_register, vec!["a".to_string()]);
assert_eq!(reconciliation.foreign_conflicts, vec!["b".to_string()]);
}
#[test]
fn reconcile_exclusive_upgrade_reregisters_own_but_refuses_new_foreign() {
let declared = vec!["a".to_string(), "c".to_string(), "d".to_string()];
let existing = vec!["a".to_string(), "d".to_string()];
let owned_previously = vec!["a".to_string()];
let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
assert_eq!(
reconciliation.to_register,
vec!["a".to_string(), "c".to_string()]
);
assert_eq!(reconciliation.foreign_conflicts, vec!["d".to_string()]);
}
#[test]
fn classify_ownership_three_way() {
let existing: HashSet<&str> = ["x", "y"].into_iter().collect();
let owned: HashSet<&str> = ["y"].into_iter().collect();
assert_eq!(classify_ownership("z", &existing, &owned), Ownership::New);
assert_eq!(
classify_ownership("y", &existing, &owned),
Ownership::OwnedReinstall
);
assert_eq!(
classify_ownership("x", &existing, &owned),
Ownership::ForeignConflict
);
}
#[test]
fn removed_since_computes_dropped_capabilities_per_kind() {
let old = RegisteredCapabilities {
mcp_server_ids: vec!["srv-a".to_string(), "srv-b".to_string()],
skill_dirs: vec!["skill-a".to_string()],
preset_ids: vec!["preset-a".to_string(), "preset-b".to_string()],
workflow_filenames: vec!["wf-a.md".to_string()],
service_ids: vec!["svc-a".to_string(), "svc-b".to_string()],
event_sink_ids: vec!["sink-a".to_string(), "sink-b".to_string()],
event_sink_grants: BTreeMap::from([(
"sink-a".to_string(),
vec![ObservationPermissionId::new("metadata")],
)]),
};
let new = RegisteredCapabilities {
mcp_server_ids: vec!["srv-a".to_string(), "srv-c".to_string()],
skill_dirs: vec!["skill-a".to_string()],
preset_ids: vec!["preset-b".to_string()],
workflow_filenames: vec!["wf-a.md".to_string()],
service_ids: vec!["svc-a".to_string()],
event_sink_ids: vec!["sink-a".to_string()],
event_sink_grants: BTreeMap::from([(
"sink-a".to_string(),
vec![
ObservationPermissionId::new("metadata"),
ObservationPermissionId::new("paths"),
],
)]),
};
let removed = new.removed_since(&old);
assert_eq!(removed.mcp_server_ids, vec!["srv-b".to_string()]);
assert!(removed.skill_dirs.is_empty());
assert_eq!(removed.preset_ids, vec!["preset-a".to_string()]);
assert!(removed.workflow_filenames.is_empty());
assert_eq!(removed.service_ids, vec!["svc-b".to_string()]);
assert_eq!(removed.event_sink_ids, vec!["sink-b".to_string()]);
assert!(removed.event_sink_grants.is_empty());
assert!(RegisteredCapabilities {
event_sink_grants: BTreeMap::from([(
"sink-a".to_string(),
vec![ObservationPermissionId::new("metadata")],
)]),
..Default::default()
}
.is_empty());
}
#[test]
fn event_sink_grants_round_trip_and_legacy_absence_defaults_empty() {
let legacy: RegisteredCapabilities = serde_json::from_value(serde_json::json!({
"event_sink_ids": ["audit-events"]
}))
.unwrap();
assert!(legacy.event_sink_grants.is_empty());
let exact = RegisteredCapabilities {
event_sink_ids: vec!["audit-events".to_string()],
event_sink_grants: BTreeMap::from([(
"audit-events".to_string(),
vec![
ObservationPermissionId::new("metadata"),
ObservationPermissionId::new("paths"),
],
)]),
..Default::default()
};
let round_trip: RegisteredCapabilities =
serde_json::from_value(serde_json::to_value(&exact).unwrap()).unwrap();
assert_eq!(round_trip, exact);
}
#[test]
fn event_sink_reconciliation_preserves_order_and_same_plugin_ownership() {
let manifest = event_sink_manifest(true, 1);
let registered = RegisteredCapabilities {
service_ids: vec!["audit-service".to_string()],
event_sink_ids: vec!["audit-events".to_string(), "orphaned".to_string()],
..Default::default()
};
let plan = reconcile_event_sinks(
&manifest,
®istered,
PluginInstallStatus::Installed,
Some(Platform::Linux),
)
.expect("reconcile owned sink");
assert_eq!(plan.deactivate_before_services, vec!["orphaned"]);
assert_eq!(
plan.service_dependencies_before_sinks,
vec!["audit-service"]
);
assert_eq!(plan.sinks_after_services.len(), 1);
assert_eq!(plan.sinks_after_services[0].id, "audit-events");
assert_eq!(
plan.sinks_after_services[0].state,
EventSinkCapabilityState::Eligible
);
let removal = registered.removal_order();
assert_eq!(
removal.event_sink_ids_before_services,
vec!["audit-events", "orphaned"]
);
assert_eq!(removal.service_ids_after_sinks, vec!["audit-service"]);
}
#[test]
fn event_sink_reconciliation_fails_closed_on_service_ownership_mismatch() {
let manifest = event_sink_manifest(true, 1);
let registered = RegisteredCapabilities {
event_sink_ids: vec!["audit-events".to_string()],
..Default::default()
};
let plan = reconcile_event_sinks(
&manifest,
®istered,
PluginInstallStatus::Installed,
Some(Platform::Linux),
)
.expect("reconcile ownership mismatch");
assert_eq!(plan.deactivate_before_services, vec!["audit-events"]);
assert!(plan.service_dependencies_before_sinks.is_empty());
assert!(plan.sinks_after_services.is_empty());
let mut malformed = manifest;
malformed.provides.event_sinks[0].protocol.name = "tool_evnet".to_string();
assert!(reconcile_event_sinks(
&malformed,
®istered,
PluginInstallStatus::Installed,
Some(Platform::Linux),
)
.is_err());
}
#[test]
fn installing_and_disabled_sinks_never_request_live_service_dependencies() {
let registered = RegisteredCapabilities {
service_ids: vec!["audit-service".to_string()],
event_sink_ids: vec!["audit-events".to_string()],
..Default::default()
};
let installing = reconcile_event_sinks(
&event_sink_manifest(true, 1),
®istered,
PluginInstallStatus::Installing,
Some(Platform::Linux),
)
.expect("reconcile installing sink");
assert!(installing.service_dependencies_before_sinks.is_empty());
assert_eq!(
installing.sinks_after_services[0].state,
EventSinkCapabilityState::Inactive {
detail: crate::manifest::EventSinkInactiveReason::InstallIncomplete,
}
);
let disabled = reconcile_event_sinks(
&event_sink_manifest(false, 1),
®istered,
PluginInstallStatus::Installed,
Some(Platform::Linux),
)
.expect("reconcile disabled sink");
assert!(disabled.service_dependencies_before_sinks.is_empty());
assert_eq!(
disabled.sinks_after_services[0].state,
EventSinkCapabilityState::Inactive {
detail: crate::manifest::EventSinkInactiveReason::ServiceDisabled,
}
);
}
#[test]
fn reconciliation_applies_the_plugin_level_platform_gate() {
let mut manifest = event_sink_manifest(true, 1);
manifest.platforms = Some(vec![Platform::Macos]);
manifest.validate().expect("macOS-only manifest");
let registered = RegisteredCapabilities {
service_ids: vec!["audit-service".to_string()],
event_sink_ids: vec!["audit-events".to_string()],
..Default::default()
};
let plan = reconcile_event_sinks(
&manifest,
®istered,
PluginInstallStatus::Installed,
Some(Platform::Linux),
)
.expect("platform-ineligible plan");
assert!(plan.service_dependencies_before_sinks.is_empty());
assert_eq!(
plan.sinks_after_services[0].state,
EventSinkCapabilityState::Inactive {
detail: crate::manifest::EventSinkInactiveReason::PlatformIneligible,
}
);
}
fn boot_candidate(
id: &str,
manifest: Option<PluginManifest>,
service_ids: &[&str],
sink_ids: &[&str],
status: PluginInstallStatus,
) -> PluginBootCandidate {
let mut installed = sample_plugin(id);
installed.status = status;
installed.registered.service_ids = service_ids.iter().map(|id| (*id).to_string()).collect();
installed.registered.event_sink_ids = sink_ids.iter().map(|id| (*id).to_string()).collect();
PluginBootCandidate {
installed,
manifest,
}
}
#[test]
fn global_boot_audit_blocks_duplicate_sink_owners_and_their_backing_services() {
let first = event_sink_manifest(true, 1);
let mut second = event_sink_manifest(true, 1);
second.id = "other-plugin".to_string();
second.provides.services[0].id = "other-service".to_string();
second.provides.event_sinks[0].service_id = "other-service".to_string();
second.validate().expect("second manifest");
let candidates = vec![
boot_candidate(
"event-plugin",
Some(first),
&["audit-service"],
&["audit-events"],
PluginInstallStatus::Installed,
),
boot_candidate(
"other-plugin",
Some(second),
&["other-service"],
&["audit-events"],
PluginInstallStatus::Installed,
),
];
let plans = reconcile_plugin_boot(&candidates, Some(Platform::Linux));
assert_eq!(plans.len(), 2);
for plan in plans {
assert!(plan.service_ids_to_start.is_empty());
assert_eq!(
plan.event_sinks.deactivate_before_services,
["audit-events"]
);
assert!(plan.event_sinks.sinks_after_services.is_empty());
assert!(plan
.issues
.contains(&PluginBootIssue::DuplicateEventSinkOwner {
id: "audit-events".to_string(),
}));
}
}
#[test]
fn global_boot_audit_blocks_duplicate_service_owners_and_dependent_sinks() {
let first = event_sink_manifest(true, 1);
let mut second = event_sink_manifest(true, 1);
second.id = "other-plugin".to_string();
second.provides.event_sinks[0].id = "other-events".to_string();
second.validate().expect("second manifest");
let candidates = vec![
boot_candidate(
"event-plugin",
Some(first),
&["audit-service"],
&["audit-events"],
PluginInstallStatus::Installed,
),
boot_candidate(
"other-plugin",
Some(second),
&["audit-service"],
&["other-events"],
PluginInstallStatus::Installed,
),
];
let plans = reconcile_plugin_boot(&candidates, Some(Platform::Linux));
assert_eq!(
plans[0].event_sinks.deactivate_before_services,
["audit-events"]
);
assert_eq!(
plans[1].event_sinks.deactivate_before_services,
["other-events"]
);
for plan in plans {
assert!(plan.service_ids_to_start.is_empty());
assert!(plan.event_sinks.sinks_after_services.is_empty());
assert!(plan
.issues
.contains(&PluginBootIssue::DuplicateServiceOwner {
id: "audit-service".to_string(),
}));
}
}
#[test]
fn global_boot_audit_blocks_duplicate_plugin_rows_but_keeps_safe_plugins() {
let first = event_sink_manifest(true, 1);
let mut second = event_sink_manifest(true, 1);
second.provides.services[0].id = "other-service".to_string();
second.provides.event_sinks[0].id = "other-events".to_string();
second.provides.event_sinks[0].service_id = "other-service".to_string();
second.validate().expect("second same-id manifest");
let mut safe = event_sink_manifest(true, 1);
safe.id = "safe-plugin".to_string();
safe.provides.services[0].id = "safe-service".to_string();
safe.provides.event_sinks[0].id = "safe-events".to_string();
safe.provides.event_sinks[0].service_id = "safe-service".to_string();
safe.validate().expect("safe manifest");
let plans = reconcile_plugin_boot(
&[
boot_candidate(
"event-plugin",
Some(first),
&["audit-service"],
&["audit-events"],
PluginInstallStatus::Installed,
),
boot_candidate(
"event-plugin",
Some(second),
&["other-service"],
&["other-events"],
PluginInstallStatus::Installed,
),
boot_candidate(
"safe-plugin",
Some(safe),
&["safe-service"],
&["safe-events"],
PluginInstallStatus::Installed,
),
],
Some(Platform::Linux),
);
for plan in &plans[..2] {
assert!(plan.service_ids_to_start.is_empty());
assert_eq!(
plan.issues,
[PluginBootIssue::DuplicatePluginId {
id: "event-plugin".to_string(),
}]
);
}
assert_eq!(plans[2].service_ids_to_start, ["safe-service"]);
assert!(plans[2].issues.is_empty());
}
#[test]
fn global_boot_audit_blocks_incomplete_identity_mismatch_and_unknown_platform() {
let manifest = event_sink_manifest(true, 1);
let installing = boot_candidate(
"event-plugin",
Some(manifest.clone()),
&["audit-service"],
&["audit-events"],
PluginInstallStatus::Installing,
);
let mut mismatch_manifest = manifest.clone();
mismatch_manifest.id = "different-plugin".to_string();
let mismatch = boot_candidate(
"event-plugin",
Some(mismatch_manifest),
&["audit-service"],
&["audit-events"],
PluginInstallStatus::Installed,
);
let installing_plan = reconcile_plugin_boot(&[installing], Some(Platform::Linux));
assert_eq!(
installing_plan[0].issues,
[PluginBootIssue::InstallIncomplete]
);
assert!(installing_plan[0].service_ids_to_start.is_empty());
let mismatch_plan = reconcile_plugin_boot(&[mismatch], Some(Platform::Linux));
assert!(matches!(
mismatch_plan[0].issues.as_slice(),
[PluginBootIssue::ManifestIdMismatch { .. }]
));
assert!(mismatch_plan[0].service_ids_to_start.is_empty());
let unknown = boot_candidate(
"event-plugin",
Some(manifest),
&["audit-service"],
&["audit-events"],
PluginInstallStatus::Installed,
);
let unknown_plan = reconcile_plugin_boot(&[unknown], None);
assert_eq!(unknown_plan[0].issues, [PluginBootIssue::UnknownPlatform]);
assert!(unknown_plan[0].service_ids_to_start.is_empty());
}
#[test]
fn reconcile_exclusive_covers_service_ids_same_as_other_kinds() {
let declared = vec!["svc-a".to_string(), "svc-b".to_string()];
let existing = vec!["svc-b".to_string(), "other-plugins-svc".to_string()];
let owned_previously: Vec<String> = vec![];
let reconciliation = reconcile_exclusive(&declared, &existing, &owned_previously);
assert_eq!(reconciliation.to_register, vec!["svc-a".to_string()]);
assert_eq!(reconciliation.foreign_conflicts, vec!["svc-b".to_string()]);
}
#[tokio::test]
async fn load_empty_file_returns_empty_registry() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("installed.json");
tokio::fs::create_dir_all(path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&path, "").await.unwrap();
let loaded = InstalledPlugins::load(&path).await.expect("load");
assert!(loaded.plugins.is_empty());
}
}