From bbca0efa26c39cc0e59c0a33ef4def7079ba09fd Mon Sep 17 00:00:00 2001 From: Chris Hayduk Date: Tue, 23 Jun 2026 01:33:29 -0400 Subject: [PATCH] plugins: verify installed cache provenance --- codex-rs/Cargo.lock | 2 + codex-rs/core-plugins/Cargo.toml | 2 + .../core-plugins/src/install_provenance.rs | 148 ++++++++++++++++++ codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/loader.rs | 62 ++++++-- codex-rs/core-plugins/src/loader_tests.rs | 46 ++++++ codex-rs/core-plugins/src/manager_tests.rs | 26 ++- codex-rs/core-plugins/src/store.rs | 122 ++++++++++++++- codex-rs/core-plugins/src/store_tests.rs | 30 ++++ 9 files changed, 412 insertions(+), 27 deletions(-) create mode 100644 codex-rs/core-plugins/src/install_provenance.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3324fbd327..bca7db6385 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2780,6 +2780,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.18", @@ -2789,6 +2790,7 @@ dependencies = [ "tracing-subscriber", "tracing-test", "url", + "walkdir", "wiremock", "zip 2.4.2", ] diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 4487238287..83d670e5d8 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -41,6 +41,7 @@ regex = { workspace = true } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } @@ -48,6 +49,7 @@ tokio = { workspace = true, features = ["fs", "macros", "rt", "time"] } toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } +walkdir = { workspace = true } zip = { workspace = true } [dev-dependencies] diff --git a/codex-rs/core-plugins/src/install_provenance.rs b/codex-rs/core-plugins/src/install_provenance.rs new file mode 100644 index 0000000000..3ef94c55da --- /dev/null +++ b/codex-rs/core-plugins/src/install_provenance.rs @@ -0,0 +1,148 @@ +use crate::store::PluginStoreError; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use walkdir::WalkDir; +pub(crate) const INSTALL_PROVENANCE_FILE: &str = ".codex-plugin-install.json"; +const INSTALL_PROVENANCE_SCHEMA_VERSION: u8 = 1; +#[derive(Debug, Deserialize, Serialize)] +struct PluginInstallProvenance { + schema_version: u8, + content_sha256: String, +} +enum TreeEntry { + Directory, + File, + VirtualFile(Vec), +} +pub(crate) fn fingerprint_plugin_tree( + root: &Path, + manifest_override: Option<&str>, +) -> Result { + let mut entries = BTreeMap::new(); + for entry in WalkDir::new(root) + .sort_by_file_name() + .into_iter() + .filter_entry(|entry| entry.depth() != 1 || entry.file_name() != ".git") + .skip(1) + { + let entry = entry.map_err(|error| { + PluginStoreError::io( + "failed to enumerate plugin source", + std::io::Error::other(error), + ) + })?; + let relative = entry.path().strip_prefix(root).map_err(|error| { + PluginStoreError::Invalid(format!("failed to fingerprint plugin path: {error}")) + })?; + let tree_entry = if entry.file_type().is_dir() { + TreeEntry::Directory + } else if entry.file_type().is_file() { + TreeEntry::File + } else { + continue; + }; + entries.insert(relative.to_path_buf(), tree_entry); + } + if let Some(manifest) = manifest_override { + entries + .entry(PathBuf::from(".codex-plugin")) + .or_insert(TreeEntry::Directory); + entries.insert( + PathBuf::from(".codex-plugin").join("plugin.json"), + TreeEntry::VirtualFile(manifest.as_bytes().to_vec()), + ); + } + let mut hasher = Sha256::new(); + for (path, entry) in entries { + hasher.update([if matches!(entry, TreeEntry::Directory) { + b'd' + } else { + b'f' + }]); + hash_contents(&mut hasher, path.as_os_str().as_encoded_bytes()); + match entry { + TreeEntry::Directory => {} + TreeEntry::File => hash_file(&mut hasher, &root.join(path))?, + TreeEntry::VirtualFile(contents) => hash_contents(&mut hasher, &contents), + } + } + Ok(format!("{:x}", hasher.finalize())) +} +pub(crate) fn read_install_fingerprint( + plugin_base_root: &Path, +) -> Result, PluginStoreError> { + let path = plugin_base_root.join(INSTALL_PROVENANCE_FILE); + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(PluginStoreError::io( + "failed to read plugin install provenance", + error, + )); + } + }; + let Ok(provenance) = serde_json::from_str::(&contents) else { + return Ok(None); + }; + let fingerprint = provenance.content_sha256; + Ok( + (provenance.schema_version == INSTALL_PROVENANCE_SCHEMA_VERSION + && fingerprint.len() == 64 + && fingerprint + .chars() + .all(|character| character.is_ascii_hexdigit())) + .then(|| fingerprint.to_ascii_lowercase()), + ) +} +pub(crate) fn write_install_fingerprint( + plugin_base_root: &Path, + content_sha256: &str, +) -> Result<(), PluginStoreError> { + let provenance = PluginInstallProvenance { + schema_version: INSTALL_PROVENANCE_SCHEMA_VERSION, + content_sha256: content_sha256.to_string(), + }; + let mut temporary = tempfile::NamedTempFile::new_in(plugin_base_root).map_err(|error| { + PluginStoreError::io( + "failed to create temporary plugin install provenance", + error, + ) + })?; + serde_json::to_writer_pretty(&mut temporary, &provenance).map_err(|error| { + PluginStoreError::Invalid(format!("failed to serialize plugin provenance: {error}")) + })?; + io::Write::write_all(&mut temporary, b"\n") + .map_err(|error| PluginStoreError::io("failed to write plugin provenance", error))?; + io::Write::flush(temporary.as_file_mut()) + .map_err(|error| PluginStoreError::io("failed to flush plugin provenance", error))?; + temporary + .persist(plugin_base_root.join(INSTALL_PROVENANCE_FILE)) + .map_err(|error| { + PluginStoreError::io("failed to persist plugin install provenance", error.error) + })?; + Ok(()) +} +fn hash_file(hasher: &mut Sha256, path: &Path) -> Result<(), PluginStoreError> { + let mut file = fs::File::open(path) + .map_err(|error| PluginStoreError::io("failed to open plugin file", error))?; + let length = file + .metadata() + .map_err(|error| PluginStoreError::io("failed to inspect plugin file", error))? + .len(); + hasher.update(length.to_le_bytes()); + io::copy(&mut file, hasher) + .map_err(|error| PluginStoreError::io("failed to read plugin file", error))?; + Ok(()) +} +fn hash_contents(hasher: &mut Sha256, contents: &[u8]) { + hasher.update((contents.len() as u64).to_le_bytes()); + hasher.update(contents); +} diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 7ee5ddd117..d94a59ea96 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -1,5 +1,6 @@ mod app_mcp_routing; mod discoverable; +mod install_provenance; pub mod installed_marketplaces; pub mod loader; mod manager; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index db99256f33..d6c8a511ec 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -151,18 +151,27 @@ async fn load_plugins_from_layer_stack_with_scope( configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); let mut plugins = Vec::with_capacity(configured_plugins.len()); - let mut seen_mcp_server_names = HashMap::::new(); + let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store, &scope).await; for name in loaded_plugin.mcp_servers.keys() { - if let Some(previous_plugin) = - seen_mcp_server_names.insert(name.clone(), configured_name.clone()) - { + if let Some((winner_plugin, winner_path)) = seen_mcp_server_names.get(name) { + let remediation = format!( + "remove `{configured_name}` or set [plugins.\"{configured_name}\"].enabled = false" + ); warn!( - plugin = configured_name, - previous_plugin, server = name, - "skipping duplicate plugin MCP server name" + winner_plugin = %winner_plugin, + winner_path = %winner_path.display(), + duplicate_plugin = %configured_name, + duplicate_path = %loaded_plugin.root.display(), + remediation = %remediation, + "duplicate plugin MCP server ownership for `{name}`: `{winner_plugin}` wins over `{configured_name}`; {remediation}" + ); + } else { + seen_mcp_server_names.insert( + name.clone(), + (configured_name.clone(), loaded_plugin.root.clone()), ); } } @@ -374,7 +383,21 @@ pub fn refresh_curated_plugin_cache( if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) { - continue; + let source_matches = store + .active_plugin_matches_source(plugin_id, source_path.as_path()) + .map_err(|err| { + format!( + "failed to verify curated plugin cache for {}: {err}", + plugin_id.as_key() + ) + })?; + if source_matches { + continue; + } + warn!( + plugin = %plugin_id.as_key(), + "curated plugin cache bytes differ from the intended source; reinstalling" + ); } store @@ -545,7 +568,28 @@ fn refresh_non_curated_plugin_cache_with_mode( if mode == NonCuratedCacheRefreshMode::IfVersionChanged && store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) { - continue; + let source_matches = match manifest_fallback_contents.as_deref() { + Some(manifest_contents) => store + .active_plugin_matches_source_with_fallback_manifest( + &plugin_id, + source_path.as_path(), + manifest_contents, + ), + None => store.active_plugin_matches_source(&plugin_id, source_path.as_path()), + } + .map_err(|err| { + format!( + "failed to verify plugin cache for {}: {err}", + plugin_id.as_key() + ) + })?; + if source_matches { + continue; + } + warn!( + plugin = %plugin_id.as_key(), + "plugin cache bytes differ from the intended source; reinstalling" + ); } match manifest_fallback_contents.as_deref() { diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index d4faaa8e7a..aadd092035 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -227,6 +227,52 @@ fn curated_plugin_cache_version_preserves_non_git_sha_versions() { assert_eq!(curated_plugin_cache_version("0123456"), "0123456"); } +#[tokio::test] +#[tracing_test::traced_test] +async fn duplicate_mcp_diagnostic_names_the_stable_winner_and_remediation() { + let codex_home = tempfile::tempdir().expect("codex home"); + for marketplace in ["a", "b", "c"] { + let plugin_root = codex_home.path().join(format!( + "plugins/cache/{marketplace}/structure-viewer/local" + )); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"structure-viewer"}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{"mcpServers":{"structure":{"command":"echo"}}}"#, + ); + } + let stack = ConfigLayerStack::new( + vec![user_layer( + user_config_path(&codex_home, "config.toml"), + "[plugins.\"structure-viewer@c\"]\nenabled = true\n[plugins.\"structure-viewer@a\"]\nenabled = true\n[plugins.\"structure-viewer@b\"]\nenabled = true\n", + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config stack"); + let store = PluginStore::new(codex_home.path().to_path_buf()); + let plugins = load_plugins_from_layer_stack( + &stack, + HashMap::new(), + &store, + /*plugin_skill_snapshots*/ None, + Some(Product::Codex), + /*prefer_remote_curated_conflicts*/ false, + ) + .await; + assert_eq!(plugins.len(), 3); + for expected in [ + "`structure-viewer@a` wins over `structure-viewer@b`", + "`structure-viewer@a` wins over `structure-viewer@c`", + "[plugins.\"structure-viewer@b\"].enabled = false", + ] { + assert!(logs_contain(expected)); + } +} + fn plugin_id() -> PluginId { PluginId::parse("demo-plugin@test-marketplace").expect("plugin id") } diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 72d357b90e..1a6cf01834 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -5153,7 +5153,7 @@ plugins = true } #[test] -fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { +fn refresh_curated_plugin_cache_reinstalls_unverified_current_version() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); @@ -5169,8 +5169,8 @@ fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_curren ); assert!( - !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) - .expect("cache refresh should be a no-op when configured plugins are current") + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should reinstall current but unverified plugin bytes") ); } @@ -5374,7 +5374,7 @@ enabled = true } #[test] -fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() { +fn refresh_non_curated_plugin_cache_reinstalls_changed_current_version() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); @@ -5395,12 +5395,6 @@ fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_cu ] }"#, ); - write_plugin_with_version( - &tmp.path().join("plugins/cache/debug"), - "sample-plugin/1.2.3", - "sample-plugin", - Some("1.2.3"), - ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] @@ -5411,13 +5405,11 @@ enabled = true "#, ); - assert!( - !refresh_non_curated_plugin_cache( - tmp.path(), - &[AbsolutePathBuf::try_from(repo_root).unwrap()], - ) - .expect("cache refresh should be a no-op when configured plugins are current") - ); + let roots = [AbsolutePathBuf::try_from(repo_root.clone()).unwrap()]; + assert!(refresh_non_curated_plugin_cache(tmp.path(), &roots).unwrap()); + fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "updated").unwrap(); + assert!(refresh_non_curated_plugin_cache(tmp.path(), &roots).unwrap()); + assert!(!refresh_non_curated_plugin_cache(tmp.path(), &roots).unwrap()); } #[test] diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 5d78fefa90..d2a1648bde 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -1,3 +1,6 @@ +use crate::install_provenance::fingerprint_plugin_tree; +use crate::install_provenance::read_install_fingerprint; +use crate::install_provenance::write_install_fingerprint; use crate::manifest::PluginManifest; use crate::manifest::load_plugin_manifest; use crate::manifest::parse_plugin_manifest; @@ -19,6 +22,7 @@ use std::path::PathBuf; pub const DEFAULT_PLUGIN_VERSION: &str = "local"; pub const PLUGINS_CACHE_DIR: &str = "plugins/cache"; pub const PLUGINS_DATA_DIR: &str = "plugins/data"; +const NO_MANIFEST_OVERRIDE: Option<&str> = None; const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json"; const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1; @@ -47,6 +51,15 @@ enum InstallManifest<'a> { Fallback(&'a str), } +impl<'a> InstallManifest<'a> { + fn contents_override(self) -> Option<&'a str> { + match self { + Self::OnDisk => None, + Self::Fallback(contents) => Some(contents), + } + } +} + impl PluginStore { pub fn new(codex_home: PathBuf) -> Self { Self::try_new(codex_home) @@ -116,6 +129,48 @@ impl PluginStore { self.active_plugin_version(plugin_id).is_some() } + pub fn active_plugin_matches_source( + &self, + plugin_id: &PluginId, + source_path: &Path, + ) -> Result { + self.active_plugin_matches_install_manifest(plugin_id, source_path, InstallManifest::OnDisk) + } + + pub(crate) fn active_plugin_matches_source_with_fallback_manifest( + &self, + plugin_id: &PluginId, + source_path: &Path, + manifest_contents: &str, + ) -> Result { + self.active_plugin_matches_install_manifest( + plugin_id, + source_path, + InstallManifest::Fallback(manifest_contents), + ) + } + + fn active_plugin_matches_install_manifest( + &self, + plugin_id: &PluginId, + source_path: &Path, + manifest: InstallManifest<'_>, + ) -> Result { + let Some(installed_root) = self.active_plugin_root(plugin_id) else { + return Ok(false); + }; + let manifest = resolve_install_manifest(source_path, manifest); + let expected = fingerprint_plugin_tree(source_path, manifest.contents_override())?; + let Some(recorded) = read_install_fingerprint(self.plugin_base_root(plugin_id).as_path())? + else { + return Ok(false); + }; + if recorded != expected { + return Ok(false); + } + Ok(fingerprint_plugin_tree(installed_root.as_path(), NO_MANIFEST_OVERRIDE)? == expected) + } + pub fn remote_plugin_id( &self, plugin_id: &PluginId, @@ -297,11 +352,26 @@ impl PluginStore { } validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?; let installed_path = self.plugin_root(&plugin_id, &plugin_version); + let source_fingerprint = + fingerprint_plugin_tree(source_path.as_path(), manifest.contents_override())?; replace_plugin_root_atomically( source_path.as_path(), self.plugin_base_root(&plugin_id).as_path(), &plugin_version, manifest, + &source_fingerprint, + )?; + let installed_fingerprint = + fingerprint_plugin_tree(installed_path.as_path(), NO_MANIFEST_OVERRIDE)?; + if installed_fingerprint != source_fingerprint { + return Err(PluginStoreError::Invalid(format!( + "installed plugin bytes do not match source for `{}`", + plugin_id.as_key() + ))); + } + write_install_fingerprint( + self.plugin_base_root(&plugin_id).as_path(), + &source_fingerprint, )?; self.remove_remote_plugin_install_metadata(&plugin_id)?; @@ -316,6 +386,49 @@ impl PluginStore { remove_existing_target(self.plugin_base_root(plugin_id).as_path()) } + pub(crate) fn other_sources( + &self, + canonical_plugin_id: &PluginId, + active_only: bool, + ) -> Result, PluginStoreError> { + let mut installed = Vec::new(); + let marketplaces = match fs::read_dir(self.root.as_path()) { + Ok(entries) => entries, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(installed), + Err(err) => { + return Err(PluginStoreError::io( + "failed to enumerate plugin cache marketplaces", + err, + )); + } + }; + for marketplace in marketplaces.filter_map(Result::ok) { + let Ok(file_type) = marketplace.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let Ok(marketplace_name) = marketplace.file_name().into_string() else { + continue; + }; + let Ok(plugin_id) = + PluginId::new(canonical_plugin_id.plugin_name.clone(), marketplace_name) + else { + continue; + }; + if &plugin_id == canonical_plugin_id + || (active_only && !self.is_installed(&plugin_id)) + || (!active_only && !self.plugin_base_root(&plugin_id).as_path().is_dir()) + { + continue; + } + installed.push(plugin_id); + } + installed.sort_unstable_by_key(PluginId::as_key); + Ok(installed) + } + fn remote_plugin_install_metadata_path(&self, plugin_id: &PluginId) -> AbsolutePathBuf { self.plugin_base_root(plugin_id) .join(REMOTE_PLUGIN_INSTALL_METADATA_FILE) @@ -351,7 +464,7 @@ pub enum PluginStoreError { } impl PluginStoreError { - fn io(context: &'static str, source: io::Error) -> Self { + pub(crate) fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } } } @@ -500,6 +613,7 @@ fn replace_plugin_root_atomically( target_root: &Path, plugin_version: &str, manifest: InstallManifest<'_>, + source_fingerprint: &str, ) -> Result<(), PluginStoreError> { let Some(parent) = target_root.parent() else { return Err(PluginStoreError::Invalid(format!( @@ -541,6 +655,12 @@ fn replace_plugin_root_atomically( fs::write(&manifest_path, contents) .map_err(|err| PluginStoreError::io("failed to write fallback plugin manifest", err))?; } + let staged_fingerprint = fingerprint_plugin_tree(&staged_version_root, NO_MANIFEST_OVERRIDE)?; + if staged_fingerprint != source_fingerprint { + return Err(PluginStoreError::Invalid( + "staged plugin bytes do not match source".to_string(), + )); + } let target_version_root = target_root.join(plugin_version); if target_root.exists() && !target_version_root.exists() { diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 237fb4e88a..22b9cbf5a5 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -192,6 +192,36 @@ fn install_with_version_uses_requested_cache_version() { assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } +#[test] +fn install_records_and_verifies_exact_source_bytes() { + let tmp = tempdir().unwrap(); + write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some("1.2.3")); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(); + let result = store + .install(source.clone(), plugin_id.clone()) + .expect("install plugin"); + let matches_source = || { + store + .active_plugin_matches_source(&plugin_id, source.as_path()) + .expect("verify installed bytes") + }; + assert!(matches_source()); + let provenance_path = store + .plugin_base_root(&plugin_id) + .join(crate::install_provenance::INSTALL_PROVENANCE_FILE); + assert!(provenance_path.is_file()); + fs::write(result.installed_path.join("skills/SKILL.md"), "tampered") + .expect("tamper installed file"); + assert!(!matches_source()); + store.install(source.clone(), plugin_id.clone()).unwrap(); + fs::write(&provenance_path, "corrupt").expect("corrupt install provenance"); + assert!(!matches_source()); + store.install(source.clone(), plugin_id.clone()).unwrap(); + fs::write(source.join("skills/SKILL.md"), "updated source").expect("update source file"); + assert!(!matches_source()); +} #[test] fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() { let tmp = tempdir().unwrap();