mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
5cf0adba93
commit
73cd831952
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user