diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 0da9fa15d4..d809dc61fb 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -171,8 +171,12 @@ pub fn remote_installed_plugins_to_config( } }; // Remote installed refresh materializes bundles before publishing state. Keep this - // check so partial refresh failures do not make missing local bundles effective. - store.active_plugin_root(&plugin_id)?; + // version check so partial refresh failures do not make missing or stale local bundles + // effective. + let release_version = plugin.release_version.as_deref()?; + if store.active_plugin_version(&plugin_id).as_deref() != Some(release_version) { + return None; + } Some(( plugin_id.as_key(), PluginConfig { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 3eed4ca9d6..afbcc4f762 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1708,7 +1708,8 @@ impl PluginsManager { let installed_plugins = crate::remote::fetch_remote_installed_plugins(service_config, auth).await?; let mut bundles_changed = false; - for plugin in &installed_plugins { + let mut publishable_plugins = Vec::new(); + for plugin in installed_plugins { let validated_bundle = match validate_remote_plugin_bundle( &plugin.id, &plugin.marketplace_name, @@ -1728,12 +1729,12 @@ impl PluginsManager { continue; } }; - if self - .store - .active_plugin_version(&validated_bundle.plugin_id) - .as_deref() - == Some(validated_bundle.plugin_version.as_str()) + let plugin_id = validated_bundle.plugin_id.clone(); + let plugin_version = validated_bundle.plugin_version.clone(); + if self.store.active_plugin_version(&plugin_id).as_deref() + == Some(plugin_version.as_str()) { + publishable_plugins.push(plugin); continue; } match download_and_install_remote_plugin_bundle( @@ -1750,6 +1751,7 @@ impl PluginsManager { path = %result.installed_path.display(), "installed remote plugin bundle during installed-plugin refresh" ); + publishable_plugins.push(plugin); } Err(err) => { warn!( @@ -1762,7 +1764,11 @@ impl PluginsManager { } } } - Ok(self.write_remote_installed_plugins_cache(installed_plugins) || bundles_changed) + let cache_changed = self.write_remote_installed_plugins_cache(publishable_plugins); + if bundles_changed { + self.clear_enabled_outcome_cache(); + } + Ok(cache_changed || bundles_changed) } fn run_non_curated_plugin_cache_refresh_loop(self: Arc) { diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 9ef975fedf..c8ad0026af 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -621,6 +621,37 @@ remote_plugin = true assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn remote_installed_cache_ignores_plugins_with_stale_local_version() { + let codex_home = TempDir::new().unwrap(); + write_plugin( + &codex_home.path().join("plugins/cache/chatgpt-global"), + "linear/1.0.0", + "linear", + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { + marketplace_name: "chatgpt-global".to_string(), + id: "plugins~Plugin_linear".to_string(), + name: "linear".to_string(), + enabled: true, + release_version: Some("2.0.0".to_string()), + bundle_download_url: Some("https://example.com/linear.tar.gz".to_string()), + }]); + + let outcome = manager.plugins_for_test_config(&config).await; + assert_eq!(outcome, PluginLoadOutcome::default()); +} + async fn mount_remote_installed_plugin_pages( server: &MockServer, global_plugins: &str, @@ -688,6 +719,40 @@ fn remote_installed_plugin_json(plugin_name: &str, enabled: bool) -> String { ) } +fn remote_installed_plugin_json_with_release( + plugin_name: &str, + enabled: bool, + version: &str, + bundle_download_url: Option<&str>, +) -> String { + let bundle_download_url = bundle_download_url + .map(|url| format!(r#""bundle_download_url": "{url}","#)) + .unwrap_or_default(); + format!( + r#"{{ + "id": "plugins~Plugin_{plugin_name}", + "name": "{plugin_name}", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": {{ + "version": "{version}", + {bundle_download_url} + "display_name": "{plugin_name}", + "description": "{plugin_name} plugin", + "app_ids": [], + "interface": {{ + "short_description": "{plugin_name}", + "capabilities": [] + }}, + "skills": [] + }}, + "enabled": {enabled}, + "disabled_skill_names": [] + }}"# + ) +} + async fn wait_for_counter(counter: &AtomicUsize, expected: usize) { tokio::time::timeout(Duration::from_secs(5), async { loop { @@ -701,6 +766,46 @@ async fn wait_for_counter(counter: &AtomicUsize, expected: usize) { .expect("counter should reach expected value"); } +#[tokio::test] +async fn remote_installed_plugins_cache_refresh_does_not_publish_stale_plugin_when_bundle_unavailable() + { + let tmp = tempfile::tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/chatgpt-global"), + "linear/1.0.0", + "linear", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true +"#, + ); + + let server = MockServer::start().await; + mount_remote_installed_plugin_pages( + &server, + &remote_installed_plugin_json_with_release("linear", true, "2.0.0", None), + "", + ) + .await; + + let config = load_config(tmp.path(), tmp.path()).await; + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let changed = manager + .refresh_remote_installed_plugins_cache( + &remote_plugin_service_config(&format!("{}/backend-api/", server.uri())), + Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), + ) + .await + .unwrap(); + + assert!(changed); + let outcome = manager.plugins_for_test_config(&config).await; + assert_eq!(outcome, PluginLoadOutcome::default()); +} + #[tokio::test] async fn remote_installed_plugins_cache_refresh_reconciles_cached_bundles_without_config_writes() { let tmp = tempfile::tempdir().unwrap();