feat: Use remote installed plugin cache for skills and MCP (#20096)

- Fetches and caches remote /installed plugin state
- Lets skills/list load skills from remote-installed cached plugins
without requiring a local marketplace entry
- Routes plugin list/startup/install/uninstall changes through async
plugin cache invalidation and MCP refresh
This commit is contained in:
xl-openai
2026-04-29 12:09:49 -07:00
committed by GitHub
Unverified
parent 5cf0adba93
commit 73cd831952
9 changed files with 751 additions and 54 deletions
+282 -9
View File
@@ -22,6 +22,7 @@ use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
use codex_core_plugins::loader::refresh_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
use codex_core_plugins::loader::remote_installed_plugins_to_config;
use codex_core_plugins::manifest::PluginManifestInterface;
use codex_core_plugins::manifest::load_plugin_manifest;
use codex_core_plugins::marketplace::MarketplaceError;
@@ -40,6 +41,8 @@ use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
use codex_core_plugins::marketplace_upgrade::upgrade_configured_git_marketplaces;
use codex_core_plugins::remote::RemoteInstalledPlugin;
use codex_core_plugins::remote::RemotePluginCatalogError;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote_legacy::RemotePluginFetchError;
use codex_core_plugins::remote_legacy::RemotePluginMutationError;
@@ -91,6 +94,30 @@ struct CachedFeaturedPluginIds {
featured_plugin_ids: Vec<String>,
}
struct RemoteInstalledPluginsCacheRefreshRequest {
service_config: RemotePluginServiceConfig,
auth: Option<CodexAuth>,
notify: RemoteInstalledPluginsCacheRefreshNotify,
// App-server attaches side effects such as skills metadata invalidation and MCP refreshes when
// remote installed state changes.
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
}
#[derive(Clone, Copy)]
enum RemoteInstalledPluginsCacheRefreshNotify {
IfCacheChanged,
// Remote mutations may change local bundles or active MCP state even when the installed set is
// unchanged. Notify after `/installed` succeeds so MCP refreshes are ordered after the remote
// installed cache.
AfterSuccessfulRefresh,
}
#[derive(Default)]
struct RemoteInstalledPluginsCacheRefreshState {
requested: Option<RemoteInstalledPluginsCacheRefreshRequest>,
in_flight: bool,
}
#[derive(Clone, PartialEq, Eq)]
struct NonCuratedCacheRefreshRequest {
roots: Vec<AbsolutePathBuf>,
@@ -333,6 +360,10 @@ pub struct PluginsManager {
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
// TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale
// remote installed state cannot remain effective for a different account.
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
remote_sync_lock: Semaphore,
restriction_product: Option<Product>,
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
@@ -363,6 +394,10 @@ impl PluginsManager {
),
non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()),
cached_enabled_outcome: RwLock::new(None),
remote_installed_plugins_cache: RwLock::new(None),
remote_installed_plugins_cache_refresh_state: RwLock::new(
RemoteInstalledPluginsCacheRefreshState::default(),
),
remote_sync_lock: Semaphore::new(/*permits*/ 1),
restriction_product,
analytics_events_client: RwLock::new(None),
@@ -407,6 +442,7 @@ impl PluginsManager {
let outcome = load_plugins_from_layer_stack(
&config.config_layer_stack,
self.remote_installed_plugin_configs(config),
&self.store,
self.restriction_product,
)
@@ -421,15 +457,19 @@ impl PluginsManager {
}
pub fn clear_cache(&self) {
let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
self.clear_enabled_outcome_cache();
let mut featured_plugin_ids_cache = match self.featured_plugin_ids_cache.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
*featured_plugin_ids_cache = None;
}
fn clear_enabled_outcome_cache(&self) {
let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
*cached_enabled_outcome = None;
}
@@ -437,14 +477,19 @@ impl PluginsManager {
pub async fn effective_skill_roots_for_layer_stack(
&self,
config_layer_stack: &ConfigLayerStack,
plugins_feature_enabled: bool,
config: &Config,
) -> Vec<AbsolutePathBuf> {
if !plugins_feature_enabled {
if !config.features.enabled(Feature::Plugins) {
return Vec::new();
}
load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product)
.await
.effective_skill_roots()
load_plugins_from_layer_stack(
config_layer_stack,
self.remote_installed_plugin_configs(config),
&self.store,
self.restriction_product,
)
.await
.effective_skill_roots()
}
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
@@ -454,6 +499,116 @@ impl PluginsManager {
}
}
fn remote_installed_plugin_configs(&self, config: &Config) -> HashMap<String, PluginConfig> {
if !config.features.enabled(Feature::RemotePlugin) {
return HashMap::new();
}
let cache = match self.remote_installed_plugins_cache.read() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
let Some(plugins) = cache.as_ref() else {
return HashMap::new();
};
remote_installed_plugins_to_config(plugins, &self.store)
}
fn write_remote_installed_plugins_cache(&self, plugins: Vec<RemoteInstalledPlugin>) -> bool {
let mut cache = match self.remote_installed_plugins_cache.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
if cache.as_ref().is_some_and(|cache| cache.eq(&plugins)) {
return false;
}
*cache = Some(plugins);
drop(cache);
self.clear_enabled_outcome_cache();
true
}
pub fn clear_remote_installed_plugins_cache(&self) -> bool {
let mut cache = match self.remote_installed_plugins_cache.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
if cache.is_none() {
return false;
}
*cache = None;
drop(cache);
self.clear_enabled_outcome_cache();
true
}
pub fn maybe_start_remote_installed_plugins_cache_refresh(
self: &Arc<Self>,
config: &Config,
auth: Option<CodexAuth>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
self.maybe_start_remote_installed_plugins_cache_refresh_with_notify(
config,
auth,
RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged,
on_effective_plugins_changed,
);
}
pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
self: &Arc<Self>,
config: &Config,
auth: Option<CodexAuth>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
self.maybe_start_remote_installed_plugins_cache_refresh_with_notify(
config,
auth,
RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh,
on_effective_plugins_changed,
);
}
fn maybe_start_remote_installed_plugins_cache_refresh_with_notify(
self: &Arc<Self>,
config: &Config,
auth: Option<CodexAuth>,
notify: RemoteInstalledPluginsCacheRefreshNotify,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return;
}
self.schedule_remote_installed_plugins_cache_refresh(
RemoteInstalledPluginsCacheRefreshRequest {
service_config: remote_plugin_service_config(config),
auth,
notify,
on_effective_plugins_changed,
},
);
}
pub fn maybe_start_plugin_list_background_tasks_for_config(
self: &Arc<Self>,
config: &Config,
auth: Option<CodexAuth>,
roots: &[AbsolutePathBuf],
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
self.maybe_start_non_curated_plugin_cache_refresh(roots);
self.maybe_start_remote_installed_plugins_cache_refresh(
config,
auth,
on_effective_plugins_changed,
);
}
fn cached_featured_plugin_ids(
&self,
cache_key: &FeaturedPluginIdsCacheKey,
@@ -1128,6 +1283,7 @@ impl PluginsManager {
self: &Arc<Self>,
config: &Config,
auth_manager: Arc<AuthManager>,
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) {
if config.features.enabled(Feature::Plugins) {
self.start_curated_repo_sync();
@@ -1189,6 +1345,21 @@ impl PluginsManager {
auth_manager.clone(),
);
if config.features.enabled(Feature::RemotePlugin) {
let config = config.clone();
let manager = Arc::clone(self);
let auth_manager = auth_manager.clone();
let on_effective_plugins_changed = on_effective_plugins_changed.clone();
tokio::spawn(async move {
let auth = auth_manager.auth().await;
manager.maybe_start_remote_installed_plugins_cache_refresh(
&config,
auth,
on_effective_plugins_changed,
);
});
}
let config = config.clone();
let manager = Arc::clone(self);
tokio::spawn(async move {
@@ -1262,6 +1433,48 @@ impl PluginsManager {
);
}
fn schedule_remote_installed_plugins_cache_refresh(
self: &Arc<Self>,
mut request: RemoteInstalledPluginsCacheRefreshRequest,
) {
let should_spawn = {
let mut state = match self.remote_installed_plugins_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
if let Some(existing_request) = state.requested.as_ref() {
if matches!(
existing_request.notify,
RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh
) {
request.notify =
RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh;
}
if request.on_effective_plugins_changed.is_none() {
request.on_effective_plugins_changed =
existing_request.on_effective_plugins_changed.clone();
}
}
state.requested = Some(request);
if state.in_flight {
false
} else {
state.in_flight = true;
true
}
};
if !should_spawn {
return;
}
let manager = Arc::clone(self);
tokio::spawn(async move {
manager
.run_remote_installed_plugins_cache_refresh_loop()
.await;
});
}
fn schedule_non_curated_plugin_cache_refresh(
self: &Arc<Self>,
roots: &[AbsolutePathBuf],
@@ -1368,6 +1581,66 @@ impl PluginsManager {
}
}
async fn run_remote_installed_plugins_cache_refresh_loop(self: Arc<Self>) {
loop {
let request = {
let mut state = match self.remote_installed_plugins_cache_refresh_state.write() {
Ok(state) => state,
Err(err) => err.into_inner(),
};
match state.requested.take() {
Some(request) => request,
None => {
state.in_flight = false;
return;
}
}
};
let installed_plugins = codex_core_plugins::remote::fetch_remote_installed_plugins(
&request.service_config,
request.auth.as_ref(),
)
.await;
match installed_plugins {
Ok(installed_plugins) => {
// TODO(remote plugins): reconcile missing or stale local bundles before
// publishing remote installed state as effective local plugin config.
let changed = self.write_remote_installed_plugins_cache(installed_plugins);
let should_notify = changed
|| matches!(
request.notify,
RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh
);
if should_notify
&& let Some(on_effective_plugins_changed) =
request.on_effective_plugins_changed
{
on_effective_plugins_changed();
}
}
Err(
RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode,
) => {
let changed = self.clear_remote_installed_plugins_cache();
if changed
&& let Some(on_effective_plugins_changed) =
request.on_effective_plugins_changed
{
on_effective_plugins_changed();
}
}
Err(err) => {
warn!(
error = %err,
"failed to refresh remote installed plugins cache"
);
}
}
}
}
fn run_non_curated_plugin_cache_refresh_loop(self: Arc<Self>) {
loop {
let request = {
@@ -16,6 +16,7 @@ use codex_config::ConfigRequirementsToml;
use codex_config::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::loader::load_plugins_from_layer_stack;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
@@ -246,6 +247,67 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
);
}
#[tokio::test]
async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() {
let codex_home = TempDir::new().unwrap();
let plugin_base = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear");
write_plugin(&plugin_base, "local", "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![
codex_core_plugins::remote::RemoteInstalledPlugin {
marketplace_name: "chatgpt-global".to_string(),
id: "plugins~Plugin_linear".to_string(),
name: "linear".to_string(),
enabled: true,
},
]);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_skill_roots(),
vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()]
);
assert_eq!(outcome.plugins().len(), 1);
assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global");
}
#[tokio::test]
async fn remote_installed_cache_ignores_plugins_missing_local_cache() {
let codex_home = TempDir::new().unwrap();
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![
codex_core_plugins::remote::RemoteInstalledPlugin {
marketplace_name: "chatgpt-global".to_string(),
id: "plugins~Plugin_linear".to_string(),
name: "linear".to_string(),
enabled: true,
},
]);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
let codex_home = TempDir::new().unwrap();
@@ -3300,6 +3362,7 @@ async fn load_plugins_ignores_project_config_files() {
let outcome = load_plugins_from_layer_stack(
&stack,
std::collections::HashMap::new(),
&PluginStore::new(codex_home.path().to_path_buf()),
Some(Product::Codex),
)