From ea66a3466f144a946c6064e76d6dc5b2992b5949 Mon Sep 17 00:00:00 2001 From: Chris Hayduk Date: Tue, 23 Jun 2026 01:33:41 -0400 Subject: [PATCH] plugins: reconcile same-name installations --- .../src/request_processors/plugins.rs | 17 +- .../tests/suite/v2/plugin_install.rs | 81 +++++++--- codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/plugin_edit.rs | 148 ++++++++++++++++++ codex-rs/core-plugins/src/manager.rs | 90 +++++++++-- .../remote/remote_installed_plugin_sync.rs | 67 ++++++-- 6 files changed, 358 insertions(+), 46 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index a98fddb5bc..94af935449 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1627,13 +1627,16 @@ impl PluginRequestProcessor { remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin") })?; - self.thread_manager - .plugins_manager() - .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( - &config.plugins_config_input(), - auth.clone(), - Some(self.effective_plugins_changed_callback()), - ); + let plugins_manager = self.thread_manager.plugins_manager(); + plugins_manager + .reconcile_remote_plugin_install(&config.plugins_config_input(), &result.plugin_id) + .await + .map_err(Self::plugin_install_error)?; + plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + &config.plugins_config_input(), + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); let plugin_metadata = self .thread_manager diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index cc76ef1eb0..28abc48c3d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -30,6 +30,8 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; @@ -215,6 +217,16 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> ) .await; configure_remote_plugin_test(codex_home.path(), &server)?; + let config_path = codex_home.path().join("config.toml"); + std::fs::write( + &config_path, + format!( + "{}\n[plugins.\"linear@debug\"]\nenabled = true\n", + std::fs::read_to_string(&config_path)? + ), + )?; + let stale_root = codex_home.path().join("plugins/cache/debug/linear"); + std::fs::create_dir_all(stale_root.join("local"))?; mount_remote_plugin_detail_with_app_manifest( &server, REMOTE_PLUGIN_ID, @@ -277,6 +289,8 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?; assert_eq!(installed_app_manifest, remote_app_manifest); assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); + assert!(!stale_root.exists()); + assert!(!std::fs::read_to_string(config_path)?.contains("linear@debug")); assert!( !codex_home .path() @@ -1680,31 +1694,35 @@ async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Resu } #[tokio::test] -async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests() -> Result<()> { +async fn plugin_install_reconciles_config_and_exposes_one_server_and_skill() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), - "[features]\nplugins = true\n", + "[features]\nplugins = true\n[plugins.\"structure-viewer@openai-internal-testing\"]\nenabled = true\n", )?; + let stale_root = codex_home + .path() + .join("plugins/cache/openai-internal-testing/structure-viewer"); + std::fs::create_dir_all(stale_root.join("local"))?; let repo_root = TempDir::new()?; write_plugin_marketplace( repo_root.path(), - "debug", - "sample-plugin", - "./sample-plugin", + "openai-monorepo", + "structure-viewer", + "./structure-viewer", /*install_policy*/ None, /*auth_policy*/ None, )?; - write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + write_plugin_source(repo_root.path(), "structure-viewer", &[])?; + let plugin_root = repo_root.path().join("structure-viewer"); + std::fs::create_dir_all(plugin_root.join("skills/structure-viewer"))?; std::fs::write( - repo_root.path().join("sample-plugin/.mcp.json"), - r#"{ - "mcpServers": { - "sample-mcp": { - "command": "echo" - } - } -}"#, + plugin_root.join("skills/structure-viewer/SKILL.md"), + "---\nname: structure-viewer\ndescription: View structures\n---\n", + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{"mcpServers":{"structure":{"command":"echo"}}}"#, )?; let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; @@ -1714,9 +1732,9 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let request_id = mcp .send_plugin_install_request(PluginInstallParams { - marketplace_path: Some(marketplace_path), + marketplace_path: Some(marketplace_path.clone()), remote_marketplace_name: None, - plugin_name: "sample-plugin".to_string(), + plugin_name: "structure-viewer".to_string(), }) .await?; let response: JSONRPCResponse = timeout( @@ -1727,14 +1745,22 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let response: PluginInstallResponse = to_response(response)?; assert_eq!(response.apps_needing_auth, Vec::::new()); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("[mcp_servers.sample-mcp]")); + assert!(!config.contains("[mcp_servers.structure]")); assert!(!config.contains("command = \"echo\"")); + let config: toml::Value = toml::from_str(&config)?; + let plugins = config["plugins"].as_table().expect("plugins table"); + assert_eq!(plugins.len(), 1); + assert_eq!( + plugins.keys().next().map(String::as_str), + Some("structure-viewer@openai-monorepo") + ); + assert!(!stale_root.exists()); let request_id = mcp .send_raw_request( "mcpServer/oauth/login", Some(json!({ - "name": "sample-mcp", + "name": "structure", })), ) .await?; @@ -1749,6 +1775,25 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests err.error.message, "OAuth login is only supported for streamable HTTP servers." ); + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "structure-viewer".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + assert_eq!(response.plugin.skills.len(), 1); + assert_eq!( + response.plugin.skills[0].name, + "structure-viewer:structure-viewer" + ); + assert_eq!(response.plugin.mcp_servers, vec!["structure"]); Ok(()) } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 1cbbbaf0a9..1b6d2cf043 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -127,6 +127,7 @@ pub use overrides::build_cli_overrides_layer; pub use plugin_edit::PluginConfigEdit; pub use plugin_edit::apply_user_plugin_config_edits; pub use plugin_edit::clear_user_plugin; +pub use plugin_edit::reconcile_user_plugin_registrations; pub use plugin_edit::set_user_plugin_enabled; pub use project_root_markers::default_project_root_markers; pub use project_root_markers::project_root_markers_from_config; diff --git a/codex-rs/config/src/plugin_edit.rs b/codex-rs/config/src/plugin_edit.rs index 63795cc6c6..d49f61f362 100644 --- a/codex-rs/config/src/plugin_edit.rs +++ b/codex-rs/config/src/plugin_edit.rs @@ -1,7 +1,9 @@ +use std::collections::BTreeSet; use std::fs; use std::io::ErrorKind; use std::path::Path; +use codex_app_server_protocol::ConfigLayerSource; use codex_utils_path::resolve_symlink_write_paths; use codex_utils_path::write_atomically; use tokio::task; @@ -11,6 +13,8 @@ use toml_edit::Table as TomlTable; use toml_edit::value; use crate::CONFIG_TOML_FILE; +use crate::ConfigLayerStack; +use crate::ConfigLayerStackOrdering; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginConfigEdit { @@ -37,6 +41,36 @@ pub async fn clear_user_plugin(codex_home: &Path, plugin_key: String) -> std::io apply_user_plugin_config_edits(codex_home, vec![PluginConfigEdit::Clear { plugin_key }]).await } +pub async fn reconcile_user_plugin_registrations( + codex_home: &Path, + config_layer_stack: Option<&ConfigLayerStack>, + plugin_key: String, + plugin_name: String, + enable_canonical: bool, +) -> std::io::Result<()> { + let target_path = codex_home.join(CONFIG_TOML_FILE); + let mut paths = BTreeSet::new(); + if let Some(config_layer_stack) = config_layer_stack { + for layer in config_layer_stack.get_user_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + if let ConfigLayerSource::User { file, .. } = &layer.name { + paths.insert(file.as_path().to_path_buf()); + } + } + } + paths.remove(&target_path); + task::spawn_blocking(move || { + for path in paths { + reconcile_plugin_config_path(&path, &plugin_key, &plugin_name, /*enable*/ false)?; + } + reconcile_plugin_config_path(&target_path, &plugin_key, &plugin_name, enable_canonical) + }) + .await + .map_err(|err| std::io::Error::other(format!("config persistence task panicked: {err}")))? +} + pub async fn apply_user_plugin_config_edits( codex_home: &Path, edits: Vec, @@ -74,6 +108,25 @@ fn apply_user_plugin_config_edits_blocking( write_atomically(&write_paths.write_path, &doc.to_string()) } +fn reconcile_plugin_config_path( + config_path: &Path, + plugin_key: &str, + plugin_name: &str, + enable: bool, +) -> std::io::Result<()> { + let write_paths = resolve_symlink_write_paths(config_path)?; + let mut doc = read_or_create_document(write_paths.read_path.as_deref())?; + let mutated = if enable { + set_plugin_enabled_exclusively(&mut doc, plugin_key, plugin_name) + } else { + remove_other_plugin_registrations(&mut doc, plugin_key, plugin_name) + }; + if mutated { + write_atomically(&write_paths.write_path, &doc.to_string())?; + } + Ok(()) +} + fn read_or_create_document(config_path: Option<&Path>) -> std::io::Result { let Some(config_path) = config_path else { return Ok(DocumentMut::new()); @@ -102,6 +155,45 @@ fn set_plugin_enabled(doc: &mut DocumentMut, plugin_key: &str, enabled: bool) -> true } +fn set_plugin_enabled_exclusively( + doc: &mut DocumentMut, + plugin_key: &str, + plugin_name: &str, +) -> bool { + let removed_duplicate = remove_other_plugin_registrations(doc, plugin_key, plugin_name); + set_plugin_enabled(doc, plugin_key, /*enabled*/ true) || removed_duplicate +} + +fn remove_other_plugin_registrations( + doc: &mut DocumentMut, + plugin_key: &str, + plugin_name: &str, +) -> bool { + let Some(plugins) = doc + .as_table_mut() + .get_mut("plugins") + .and_then(ensure_table_for_read) + else { + return false; + }; + let duplicate_keys = plugins + .iter() + .map(|(key, _)| key) + .filter(|key| { + *key != plugin_key + && key.rsplit_once('@').is_some_and(|(name, marketplace)| { + name == plugin_name && !marketplace.is_empty() + }) + }) + .map(str::to_string) + .collect::>(); + let mut removed = false; + for key in duplicate_keys { + removed |= plugins.remove(&key).is_some(); + } + removed +} + fn clear_plugin(doc: &mut DocumentMut, plugin_key: &str) -> bool { let root = doc.as_table_mut(); let Some(plugins_item) = root.get_mut("plugins") else { @@ -180,6 +272,10 @@ fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) { #[cfg(test)] mod tests { use super::*; + use crate::ConfigLayerEntry; + use crate::config_requirements::ConfigRequirements; + use crate::config_requirements::ConfigRequirementsToml; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -239,6 +335,58 @@ source = "/tmp/plugin" assert_eq!(config, expected); } + #[tokio::test] + async fn exclusive_registration_reconciles_active_profile_layers() { + let codex_home = TempDir::new().unwrap(); + let base_path = codex_home.path().join(CONFIG_TOML_FILE); + let profile_path = codex_home.path().join("work.config.toml"); + let base_config = "[plugins.\"structure-viewer@openai-internal-testing\"]\nenabled = true\n[plugins.\"sequence-viewer@openai-internal-testing\"]\nenabled = true\n"; + let profile_config = "[plugins.\"structure-viewer@debug\"]\nenabled = true\n"; + fs::write(&base_path, base_config).unwrap(); + fs::write(&profile_path, profile_config).unwrap(); + let layer = |file: &Path, profile: Option<&str>, config: &str| { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: AbsolutePathBuf::try_from(file.to_path_buf()).unwrap(), + profile: profile.map(str::to_string), + }, + toml::from_str(config).unwrap(), + ) + }; + let stack = ConfigLayerStack::new( + vec![ + layer(&base_path, None, base_config), + layer(&profile_path, Some("work"), profile_config), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .unwrap(); + reconcile_user_plugin_registrations( + codex_home.path(), + Some(&stack), + "structure-viewer@openai-monorepo".to_string(), + "structure-viewer".to_string(), + /*enable_canonical*/ true, + ) + .await + .unwrap(); + let parse = |path: &Path| { + toml::from_str::(&fs::read_to_string(path).unwrap()).unwrap() + }; + assert_eq!( + parse(&base_path), + toml::from_str::( + "[plugins.\"sequence-viewer@openai-internal-testing\"]\nenabled = true\n[plugins.\"structure-viewer@openai-monorepo\"]\nenabled = true\n" + ) + .unwrap(), + ); + assert_eq!( + parse(&profile_path), + toml::from_str::("").unwrap(), + ); + } + #[tokio::test] async fn clear_user_plugin_removes_empty_plugins_table() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b357c5b536..2ab8d0d837 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -56,7 +56,7 @@ use crate::tool_suggest_metadata::ToolSuggestMetadataCache; use codex_analytics::AnalyticsEventsClient; use codex_config::ConfigLayerStack; use codex_config::clear_user_plugin; -use codex_config::set_user_plugin_enabled; +use codex_config::reconcile_user_plugin_registrations; use codex_config::types::PluginConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; @@ -365,6 +365,7 @@ pub struct PluginsManager { restriction_product: Option, auth_mode: RwLock>, analytics_events_client: RwLock>, + plugin_mutation_lock: Semaphore, } #[derive(Clone)] @@ -437,6 +438,7 @@ impl PluginsManager { restriction_product, auth_mode: RwLock::new(auth_mode), analytics_events_client: RwLock::new(None), + plugin_mutation_lock: Semaphore::new(/*permits*/ 1), } } @@ -1258,7 +1260,10 @@ impl PluginsManager { ) -> Result { let resolved = self.resolve_installable_plugin(config_layer_stack, &request)?; let plugin_id = resolved.plugin_id.clone(); - match self.install_resolved_plugin(resolved).await { + match self + .install_resolved_plugin(resolved, Some(config_layer_stack)) + .await + { Ok(outcome) => Ok(outcome), Err(err) => { self.track_plugin_install_failed( @@ -1331,7 +1336,10 @@ impl PluginsManager { return Err(err); } let plugin_id = resolved.plugin_id.clone(); - match self.install_resolved_plugin(resolved).await { + match self + .install_resolved_plugin(resolved, Some(&config.config_layer_stack)) + .await + { Ok(outcome) => Ok(outcome), Err(err) => { self.track_plugin_install_failed( @@ -1402,7 +1410,13 @@ impl PluginsManager { async fn install_resolved_plugin( &self, resolved: ResolvedMarketplacePlugin, + config_layer_stack: Option<&ConfigLayerStack>, ) -> Result { + let _mutation_guard = self + .plugin_mutation_lock + .acquire() + .await + .unwrap_or_else(|_| unreachable!("plugin mutation lock should stay open")); let auth_policy = resolved.policy.authentication; let plugin_version = if is_openai_curated_marketplace_name(&resolved.plugin_id.marketplace_name) { @@ -1449,13 +1463,12 @@ impl PluginsManager { .await .map_err(PluginInstallError::join)??; - set_user_plugin_enabled( - &self.codex_home, - result.plugin_id.as_key(), - /*enabled*/ true, + self.reconcile_plugin_install_locked( + &result.plugin_id, + config_layer_stack, + /*enable_canonical*/ true, ) - .await - .map_err(anyhow::Error::from)?; + .await?; let analytics_events_client = match self.analytics_events_client.read() { Ok(client) => client.clone(), @@ -1476,6 +1489,60 @@ impl PluginsManager { }) } + pub async fn reconcile_remote_plugin_install( + &self, + config: &PluginsConfigInput, + plugin_id: &PluginId, + ) -> Result<(), PluginInstallError> { + let _mutation_guard = self + .plugin_mutation_lock + .acquire() + .await + .unwrap_or_else(|_| unreachable!("plugin mutation lock should stay open")); + self.reconcile_plugin_install_locked( + plugin_id, + Some(&config.config_layer_stack), + /*enable_canonical*/ false, + ) + .await + } + + async fn reconcile_plugin_install_locked( + &self, + plugin_id: &PluginId, + config_layer_stack: Option<&ConfigLayerStack>, + enable_canonical: bool, + ) -> Result<(), PluginInstallError> { + reconcile_user_plugin_registrations( + &self.codex_home, + config_layer_stack, + plugin_id.as_key(), + plugin_id.plugin_name.clone(), + enable_canonical, + ) + .await + .map_err(anyhow::Error::from)?; + let store = self.store.clone(); + let canonical_plugin_id = plugin_id.clone(); + let removed_plugin_ids = tokio::task::spawn_blocking(move || { + let removed = store.other_sources(&canonical_plugin_id, /*active_only*/ false)?; + removed + .iter() + .try_for_each(|plugin_id| store.uninstall(plugin_id))?; + Ok::<_, PluginStoreError>(removed) + }) + .await + .map_err(PluginInstallError::join)??; + for removed_plugin_id in removed_plugin_ids { + tracing::info!( + canonical_plugin = %plugin_id.as_key(), + removed_plugin = %removed_plugin_id.as_key(), + "removed stale duplicate plugin installation" + ); + } + Ok(()) + } + pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { let plugin_id = PluginId::parse(&plugin_id)?; self.uninstall_plugin_id(plugin_id).await @@ -1503,6 +1570,11 @@ impl PluginsManager { } async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { + let _mutation_guard = self + .plugin_mutation_lock + .acquire() + .await + .unwrap_or_else(|_| unreachable!("plugin mutation lock should stay open")); let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { Some( self.telemetry_metadata_for_installed_plugin(&plugin_id) diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 2a23b066ed..2ee47db09c 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -187,10 +187,6 @@ pub async fn sync_remote_installed_plugin_bundles_once( for installed_plugin in installed_plugins { let plugin = installed_plugin.plugin; let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); - installed_plugin_names_by_marketplace - .entry(marketplace_name.clone()) - .or_default() - .insert(plugin.name.clone()); let plugin_id = match PluginId::new(plugin.name.clone(), marketplace_name.clone()) { Ok(plugin_id) => plugin_id, Err(err) => { @@ -205,6 +201,13 @@ pub async fn sync_remote_installed_plugin_bundles_once( continue; } }; + if has_active_local_competitor(&store, &plugin_id)? { + continue; + } + installed_plugin_names_by_marketplace + .entry(marketplace_name.clone()) + .or_default() + .insert(plugin.name.clone()); let release_version = plugin .release .version @@ -254,7 +257,11 @@ pub async fn sync_remote_installed_plugin_bundles_once( .await { Ok(result) => { - installed_plugin_ids.insert(result.plugin_id.as_key()); + if !has_active_local_competitor(&store, &result.plugin_id)? { + installed_plugin_ids.insert(result.plugin_id.as_key()); + } else { + store.uninstall(&result.plugin_id)?; + } } Err(err) => { warn!( @@ -286,6 +293,15 @@ pub async fn sync_remote_installed_plugin_bundles_once( }) } +fn has_active_local_competitor( + store: &PluginStore, + plugin_id: &PluginId, +) -> Result { + Ok(store + .other_sources(plugin_id, /*active_only*/ true)? + .iter() + .any(|other| RemotePluginScope::from_marketplace_name(&other.marketplace_name).is_none())) +} pub fn mark_remote_plugin_cache_mutation_in_flight( codex_home: &Path, marketplace_name: &str, @@ -479,7 +495,7 @@ mod tests { } #[tokio::test] - async fn sync_backfills_remote_plugin_install_metadata_for_current_bundle() { + async fn sync_backfills_metadata_and_keeps_local_same_name_install_canonical() { let server = MockServer::start().await; let codex_home = tempfile::tempdir().expect("create codex home"); let cached_manifest = codex_home @@ -494,6 +510,11 @@ mod tests { .expect("create cached plugin manifest parent"); std::fs::write(&cached_manifest, r#"{"name":"linear","version":"1.2.3"}"#) .expect("write cached plugin manifest"); + assert!(fs::create_dir_all(codex_home.path().join("plugins/cache/debug/linear")).is_ok()); + let stale_remote = codex_home + .path() + .join("plugins/cache/workspace-shared-with-me-private/linear/local"); + assert!(fs::create_dir_all(&stale_remote).is_ok()); let remote_plugin_id = "plugins~Plugin_linear"; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) @@ -517,7 +538,7 @@ mod tests { }], "pagination": {"next_page_token": null}, }))) - .expect(1) + .expect(2) .mount(&server) .await; Mock::given(method("GET")) @@ -528,7 +549,7 @@ mod tests { "plugins": [], "pagination": {"next_page_token": null}, }))) - .expect(1) + .expect(2) .mount(&server) .await; Mock::given(method("GET")) @@ -539,7 +560,7 @@ mod tests { "plugins": [], "pagination": {"next_page_token": null}, }))) - .expect(1) + .expect(2) .mount(&server) .await; let config = RemotePluginServiceConfig { @@ -555,18 +576,23 @@ mod tests { .await .expect("sync current remote plugin bundle"); - assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default()); + assert_eq!( + outcome.removed_cache_plugin_ids, + vec!["linear@workspace-shared-with-me-private".to_string()] + ); + assert!(!stale_remote.exists()); let plugin_id = PluginId::new( "linear".to_string(), REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), ) .expect("valid plugin id"); - let metadata_path = PluginStore::new(codex_home.path().to_path_buf()) + let store = PluginStore::new(codex_home.path().to_path_buf()); + let metadata = store .plugin_base_root(&plugin_id) .join(".codex-remote-plugin-install.json"); assert_eq!( serde_json::from_str::( - &std::fs::read_to_string(metadata_path.as_path()) + &std::fs::read_to_string(metadata.as_path()) .expect("read remote plugin install metadata") ) .expect("parse remote plugin install metadata"), @@ -575,6 +601,23 @@ mod tests { "remote_plugin_id": remote_plugin_id, }) ); + let local_manifest = codex_home + .path() + .join("plugins/cache/debug/linear/local/.codex-plugin/plugin.json"); + std::fs::create_dir_all(local_manifest.parent().expect("manifest parent")) + .expect("create local plugin manifest parent"); + std::fs::write(&local_manifest, r#"{"name":"linear"}"#) + .expect("write local plugin manifest"); + store.uninstall(&plugin_id).expect("remove remote cache"); + let outcome = sync_remote_installed_plugin_bundles_once( + codex_home.path().to_path_buf(), + &config, + Some(&auth), + ) + .await + .expect("sync after local plugin becomes canonical"); + assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default()); + assert!(local_manifest.is_file() && !cached_manifest.exists()); } #[test]