Refresh runtimes for remote plugin state changes (#41396)

## Why

Enabling, disabling, or reinstalling a cached remote plugin can change its
effective MCP servers, hooks, and skills without materializing a new bundle.
These transitions need to invalidate plugin consumers just like bundle updates
and removals do.

## What changed

- Report a unified set of changed remote plugins and their affected
  capabilities for materializations, removals, and installed-state changes.
- Derive enablement and reinstall changes by comparing installed snapshots,
  while keeping materialization metadata separate for hook trust handling.
- Preserve removal notifications even when stale cache cleanup fails, and use
  all reported changes to refresh loaded plugin state and runtime consumers.

## Testing

Added coverage for cached plugin enablement, disablement, removal, and
reinstallation, including removal when cache cleanup cannot complete.

GitOrigin-RevId: 86d9c1356f7cf70b3c85effebc3e5986b931bdba
This commit is contained in:
Matthew Zeng
2026-08-28 18:32:36 +00:00
committed by copyberry
parent b836aecd4d
commit bb998aeb8e
7 changed files with 245 additions and 37 deletions

View File

@@ -14,7 +14,6 @@ fn only_workspace_listed_materializations_are_eligible() {
scope,
discoverability,
authenticated_account_id: Some("account-123".to_string()),
capabilities: Default::default(),
}
};

View File

@@ -57,7 +57,9 @@ use crate::remote::RecommendedPluginsMode;
use crate::remote::RemoteInstalledPlugin;
use crate::remote::RemoteInstalledPluginBundleSyncError;
use crate::remote::RemoteInstalledPluginBundleSyncOutcome;
use crate::remote::RemotePluginCapabilities;
use crate::remote::RemotePluginCatalogError;
use crate::remote::RemotePluginChange;
use crate::remote::RemotePluginMaterialization;
use crate::remote::RemotePluginScope;
use crate::remote::RemotePluginServiceConfig;
@@ -1502,12 +1504,12 @@ impl PluginsManager {
Ok((outcome, effective_plugins_changed)) => {
tracing::info!(
materialized_remote_plugins = ?outcome.materialized_remote_plugins,
removed_plugins = ?outcome.removed_plugins,
changed_plugins = ?outcome.changed_plugins,
failed_remote_plugin_ids = ?outcome.failed_remote_plugin_ids,
failed_materialization_remote_plugin_ids = ?outcome.failed_materialization_remote_plugin_ids,
"completed remote installed plugin bundle sync"
);
if (effective_plugins_changed || outcome.changed_local_cache())
if (effective_plugins_changed || !outcome.changed_plugins.is_empty())
&& let Some(on_effective_plugins_changed) = on_effective_plugins_changed
{
on_effective_plugins_changed(EffectivePluginsChange {
@@ -1578,13 +1580,72 @@ impl PluginsManager {
generation,
committed: false,
};
let previous_enabled = {
let cache = match self.remote_installed_plugins_cache.read() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache.plugins.as_ref().map(|plugins| {
plugins
.iter()
.filter_map(|plugin| {
PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone())
.ok()
.map(|plugin_id| (plugin_id, plugin.enabled))
})
.collect::<HashMap<_, _>>()
})
};
let previous_plugin_ids = previous_enabled
.iter()
.flat_map(HashMap::keys)
.cloned()
.collect::<Vec<_>>();
let result = crate::remote::sync_remote_installed_plugin_bundles_once_with_snapshot(
self.codex_home.clone(),
&remote_plugin_service_config(config),
auth,
&previous_plugin_ids,
)
.await?;
let outcome = result.outcome;
let mut outcome = result.outcome;
// The generation fence keeps this comparison on the snapshot replaced by this pass.
// In a known snapshot, absence means inactive: cached reinstalls need the same hints
// as re-enablement, without becoming materializations or triggering hook-trust writes.
if let Some(previous_enabled) = previous_enabled {
for plugin in &result.installed_plugins {
let plugin_id = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone())
.map_err(|err| RemotePluginCatalogError::UnexpectedResponse(err.to_string()))?;
if previous_enabled
.get(&plugin_id)
.copied()
.unwrap_or_default()
== plugin.enabled
{
continue;
}
let plugin_key = plugin_id.as_key();
if outcome
.changed_plugins
.iter()
.any(|change| change.plugin_id == plugin_key)
|| self.store.active_plugin_root(&plugin_id).is_none()
{
continue;
}
let mut capabilities = RemotePluginCapabilities::default();
capabilities
.include_active_bundle(&self.store, &plugin_id)
.await;
outcome.changed_plugins.push(RemotePluginChange {
plugin_id: plugin_key,
capabilities,
});
}
}
outcome
.changed_plugins
.sort_unstable_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
let Some(effective_plugins_changed) = self.write_remote_installed_plugins_cache_snapshot(
generation,
result.installed_plugins,
@@ -1594,7 +1655,7 @@ impl PluginsManager {
return Err(RemoteInstalledPluginBundleSyncError::Superseded);
};
reconciliation.committed = true;
if !effective_plugins_changed && outcome.changed_local_cache() {
if !effective_plugins_changed && !outcome.changed_plugins.is_empty() {
self.clear_loaded_plugins_cache();
}
Ok((outcome, effective_plugins_changed))

View File

@@ -7090,7 +7090,6 @@ fn remote_installed_plugins_cache_refresh_coalesces_materializations() {
scope: crate::remote::RemotePluginScope::Workspace,
discoverability: Some(crate::remote::RemotePluginShareDiscoverability::Listed),
authenticated_account_id: Some("account-123".to_string()),
capabilities: Default::default(),
};
let change = |name: &str| EffectivePluginsChange {
materialized_remote_plugins: vec![materialization(name)],
@@ -7206,6 +7205,121 @@ remote_plugin = true
server.verify().await;
}
#[tokio::test]
async fn reconcile_remote_installed_plugins_reports_cached_state_changes() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
"[features]\nplugins = true\n",
);
write_cached_plugin(
codex_home.path(),
REMOTE_WORKSPACE_MARKETPLACE_NAME,
"linear",
);
let plugin_root = codex_home
.path()
.join("plugins/cache/workspace-directory/linear/local");
write_file(
&plugin_root.join(".mcp.json"),
r#"{"mcpServers":{"example":{"command":"unused"}}}"#,
);
write_file(
&plugin_root.join("hooks/hooks.json"),
r#"{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"echo hook"}]}]}}"#,
);
let server = MockServer::start().await;
let mut config = load_config(codex_home.path(), codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt));
let auth = auth_manager.auth_cached().expect("test ChatGPT auth");
let manager = test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
auth_manager,
);
let change = RemotePluginChange {
plugin_id: "linear@workspace-directory".to_string(),
capabilities: RemotePluginCapabilities {
has_mcps: true,
has_hooks: true,
has_skills: true,
..Default::default()
},
};
// None represents an uninstall. Reinstalling its retained bundle must report an
// activation even though the known previous snapshot has no entry for the plugin.
for (enabled, changes) in [
(Some(true), Vec::new()),
(Some(false), vec![change.clone()]),
(Some(false), Vec::new()),
(Some(true), vec![change.clone()]),
(None, vec![change.clone()]),
(Some(true), vec![change.clone()]),
(Some(true), Vec::new()),
] {
if enabled.is_none() {
// Fail cleanup before it reaches the bundle; removal hints must survive.
write_file(
&codex_home
.path()
.join("plugins/cache/openai-curated-remote"),
"not a directory",
);
}
let plugins = enabled
.into_iter()
.map(|enabled| {
serde_json::json!({
"id": "plugins~Plugin_linear",
"name": "linear",
"scope": "WORKSPACE",
"discoverability": "LISTED",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "local",
"display_name": "Linear",
"description": "Test plugin",
"interface": {},
},
"enabled": enabled,
})
})
.collect::<Vec<_>>();
// No download URL: every installed pass must reuse the cached version.
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": plugins,
"pagination": { "next_page_token": null },
})))
.expect(1)
.mount(&server)
.await;
assert_eq!(
manager
.reconcile_remote_installed_plugins(&config, Some(&auth))
.await
.expect("reconcile cached plugin state"),
RemoteInstalledPluginBundleSyncOutcome {
changed_plugins: changes,
..Default::default()
}
);
assert!(plugin_root.exists());
if enabled.is_none() {
assert_eq!(
manager.plugins_for_config(&config).await,
PluginLoadOutcome::default()
);
}
server.verify().await;
server.reset().await;
}
}
#[tokio::test]
async fn reconcile_remote_installed_plugins_rejects_incomplete_snapshot_without_cleanup() {
let codex_home = TempDir::new().unwrap();

View File

@@ -60,8 +60,8 @@ pub use plugin_capabilities::RemotePluginCapabilities;
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome;
pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
pub use remote_installed_plugin_sync::RemotePluginChange;
pub use remote_installed_plugin_sync::RemotePluginMaterialization;
pub use remote_installed_plugin_sync::RemotePluginRemoval;
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
pub(crate) use remote_installed_plugin_sync::remote_installed_plugin_bundle_sync_gate;
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;

View File

@@ -13,7 +13,7 @@ use crate::store::PluginStore;
use codex_hooks::plugin_hook_declarations;
use codex_plugin::PluginId;
/// Runtime categories affected by a bundle change, before runtime policy filtering.
/// Runtime categories affected by a plugin change, before runtime policy filtering.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RemotePluginCapabilities {
pub has_mcps: bool,
@@ -23,7 +23,7 @@ pub struct RemotePluginCapabilities {
}
impl RemotePluginCapabilities {
pub(super) async fn include_active_bundle(
pub(crate) async fn include_active_bundle(
&mut self,
store: &PluginStore,
plugin_id: &PluginId,

View File

@@ -2,7 +2,7 @@
use super::*;
use crate::remote::RemoteInstalledPluginBundleSyncOutcome;
use crate::remote::RemotePluginRemoval;
use crate::remote::RemotePluginChange;
use crate::remote::RemotePluginServiceConfig;
use crate::remote::sync_remote_installed_plugin_bundles_once;
use crate::test_support::write_file;
@@ -141,7 +141,7 @@ async fn capabilities_union_cached_versions_and_sync_reports_removal() -> anyhow
)
.await?,
RemoteInstalledPluginBundleSyncOutcome {
removed_plugins: vec![RemotePluginRemoval {
changed_plugins: vec![RemotePluginChange {
plugin_id: plugin_id.as_key(),
capabilities: RemotePluginCapabilities {
has_apps: true,

View File

@@ -45,21 +45,22 @@ pub struct RemotePluginMaterialization {
pub scope: RemotePluginScope,
pub discoverability: Option<RemotePluginShareDiscoverability>,
pub authenticated_account_id: Option<String>,
/// Runtime categories declared by either the old or new bundle.
pub capabilities: RemotePluginCapabilities,
}
/// A removed cache entry and the runtime categories declared by its previous bundle.
/// A local plugin and the runtime categories affected by its bundle or installed-state change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginRemoval {
pub struct RemotePluginChange {
pub plugin_id: String,
pub capabilities: RemotePluginCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RemoteInstalledPluginBundleSyncOutcome {
/// Internal provenance for materialization-owned hook trust, not runtime change reporting.
pub materialized_remote_plugins: Vec<RemotePluginMaterialization>,
pub removed_plugins: Vec<RemotePluginRemoval>,
/// Affected plugins with capabilities from either side of the change, including removals.
/// Installed-state removals do not depend on cache cleanup succeeding.
pub changed_plugins: Vec<RemotePluginChange>,
pub failed_remote_plugin_ids: Vec<String>,
/// Failures that leave an otherwise valid installed plugin unavailable locally.
pub failed_materialization_remote_plugin_ids: Vec<String>,
@@ -70,12 +71,6 @@ pub(crate) struct RemoteInstalledPluginBundleSyncResult {
pub(crate) installed_plugins: Vec<RemoteInstalledPlugin>,
}
impl RemoteInstalledPluginBundleSyncOutcome {
pub fn changed_local_cache(&self) -> bool {
!self.materialized_remote_plugins.is_empty() || !self.removed_plugins.is_empty()
}
}
#[derive(Debug, thiserror::Error)]
pub enum RemoteInstalledPluginBundleSyncError {
#[error("{0}")]
@@ -123,8 +118,13 @@ pub async fn sync_remote_installed_plugin_bundles_once(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<RemoteInstalledPluginBundleSyncOutcome, RemoteInstalledPluginBundleSyncError> {
let result =
sync_remote_installed_plugin_bundles_once_with_snapshot(codex_home, config, auth).await?;
let result = sync_remote_installed_plugin_bundles_once_with_snapshot(
codex_home,
config,
auth,
/*previous_plugin_ids*/ &[],
)
.await?;
Ok(result.outcome)
}
@@ -132,6 +132,7 @@ pub(crate) async fn sync_remote_installed_plugin_bundles_once_with_snapshot(
codex_home: PathBuf,
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
previous_plugin_ids: &[PluginId],
) -> Result<RemoteInstalledPluginBundleSyncResult, RemoteInstalledPluginBundleSyncError> {
let auth = ensure_chatgpt_auth(auth)?;
let authenticated_account_id = auth.get_account_id();
@@ -162,6 +163,29 @@ pub(crate) async fn sync_remote_installed_plugin_bundles_once_with_snapshot(
validated_installed_plugins.push((installed_plugin, cached_plugin, plugin_id));
}
let store = PluginStore::try_new(codex_home.clone())?;
let installed_plugin_ids = validated_installed_plugins
.iter()
.map(|(_, _, plugin_id)| plugin_id.as_key())
.collect::<BTreeSet<_>>();
let mut changed_plugins = BTreeMap::new();
// Metadata publication removes these plugins even when cleanup fails or partially deletes
// a bundle. Capture cached capabilities first so consumers can still invalidate runtimes;
// plugins that were never available locally have no previous bundle to invalidate.
for plugin_id in previous_plugin_ids {
let key = plugin_id.as_key();
if installed_plugin_ids.contains(&key) || store.active_plugin_root(plugin_id).is_none() {
continue;
}
let mut capabilities = RemotePluginCapabilities::default();
capabilities.include_active_bundle(&store, plugin_id).await;
changed_plugins.insert(
key.clone(),
RemotePluginChange {
plugin_id: key,
capabilities,
},
);
}
let mut installed_plugin_names_by_marketplace =
BTreeMap::<String, BTreeSet<String>>::from_iter([
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
@@ -261,6 +285,13 @@ pub(crate) async fn sync_remote_installed_plugin_bundles_once_with_snapshot(
Ok(result) => {
let plugin_id = result.plugin_id;
capabilities.include_active_bundle(&store, &plugin_id).await;
changed_plugins.insert(
plugin_id.as_key(),
RemotePluginChange {
plugin_id: plugin_id.as_key(),
capabilities,
},
);
materialized_remote_plugins.insert(
plugin_id.as_key(),
RemotePluginMaterialization {
@@ -268,7 +299,6 @@ pub(crate) async fn sync_remote_installed_plugin_bundles_once_with_snapshot(
scope,
discoverability,
authenticated_account_id: authenticated_account_id.clone(),
capabilities,
},
);
}
@@ -292,22 +322,26 @@ pub(crate) async fn sync_remote_installed_plugin_bundles_once_with_snapshot(
.cmp(&right.marketplace_name)
.then_with(|| left.id.cmp(&right.id))
});
let mut removed_plugins = Vec::new();
let mut removed_cache_plugins = Vec::new();
if let Err(err) = remove_stale_remote_plugin_caches(
&store,
&installed_plugin_names_by_marketplace,
&mut removed_plugins,
&mut removed_cache_plugins,
)
.await
{
warn!(error = %err, "failed to remove stale remote plugin cache entries");
}
removed_plugins.sort_unstable_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
for plugin in removed_cache_plugins {
changed_plugins
.entry(plugin.plugin_id.clone())
.or_insert(plugin);
}
Ok(RemoteInstalledPluginBundleSyncResult {
outcome: RemoteInstalledPluginBundleSyncOutcome {
materialized_remote_plugins: materialized_remote_plugins.into_values().collect(),
removed_plugins,
changed_plugins: changed_plugins.into_values().collect(),
failed_remote_plugin_ids: failed_remote_plugin_ids.into_iter().collect(),
failed_materialization_remote_plugin_ids: failed_materialization_remote_plugin_ids
.into_iter()
@@ -358,7 +392,7 @@ impl Drop for RemotePluginCacheMutationGuard {
async fn remove_stale_remote_plugin_caches(
store: &PluginStore,
installed_plugin_names_by_marketplace: &BTreeMap<String, BTreeSet<String>>,
removed_plugins: &mut Vec<RemotePluginRemoval>,
removed_plugins: &mut Vec<RemotePluginChange>,
) -> Result<(), String> {
let codex_home = store.codex_home().as_path();
for marketplace_name in [
@@ -428,7 +462,7 @@ async fn remove_stale_remote_plugin_caches(
let plugin_key = plugin_id
.map(|plugin_id| plugin_id.as_key())
.unwrap_or_else(|_| format!("{plugin_name}@{marketplace_name}"));
removed_plugins.push(RemotePluginRemoval {
removed_plugins.push(RemotePluginChange {
plugin_id: plugin_key,
capabilities,
});
@@ -707,9 +741,9 @@ mod tests {
outcome,
RemoteInstalledPluginBundleSyncOutcome {
materialized_remote_plugins: Vec::new(),
removed_plugins: removed_cache_plugin_ids
changed_plugins: removed_cache_plugin_ids
.into_iter()
.map(|plugin_id| RemotePluginRemoval {
.map(|plugin_id| RemotePluginChange {
plugin_id,
capabilities: RemotePluginCapabilities::default(),
})
@@ -814,7 +848,7 @@ mod tests {
)
.await
.expect("cleanup while install is guarded");
assert_eq!(removed, Vec::<RemotePluginRemoval>::new());
assert_eq!(removed, Vec::<RemotePluginChange>::new());
assert!(cached_manifest.is_file());
drop(guard);
@@ -826,7 +860,7 @@ mod tests {
)
.await
.expect("cleanup while second install guard is still active");
assert_eq!(removed, Vec::<RemotePluginRemoval>::new());
assert_eq!(removed, Vec::<RemotePluginChange>::new());
assert!(cached_manifest.is_file());
drop(second_guard);
@@ -840,7 +874,7 @@ mod tests {
.expect("cleanup after install guard is dropped");
assert_eq!(
removed,
vec![RemotePluginRemoval {
vec![RemotePluginChange {
plugin_id: "linear@openai-curated-remote".to_string(),
capabilities: RemotePluginCapabilities::default(),
}]
@@ -935,7 +969,7 @@ mod tests {
"created-by-me-plugin@created-by-me-remote",
"private-plugin@workspace-shared-with-me-private",
]
.map(|plugin_id| RemotePluginRemoval {
.map(|plugin_id| RemotePluginChange {
plugin_id: plugin_id.to_string(),
capabilities: RemotePluginCapabilities::default(),
})