Fix remote installed bundle cache publication

This commit is contained in:
xli-oai
2026-04-29 21:47:48 -07:00
parent e0856c6505
commit 110a381d6a
3 changed files with 124 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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<Self>) {

View File

@@ -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();