Centralize remote plugin mutations in PluginsManager (#42114)

## What changed

- Move remote plugin install and uninstall orchestration from the app server into shared `PluginsManager` APIs.
- Keep cache and backend mutations coordinated by the installed-plugin sync gate, and retain install outcomes long enough to protect newly materialized bundles during downstream setup.
- Return structured operation errors and outcomes so callers can preserve JSON-RPC error mapping, telemetry, cache refreshes, and OAuth setup.

## Testing

- Add regression coverage that verifies uninstall holds the mutation gate, preserves the local cache when the backend operation fails, and refreshes installed state after a successful uninstall.

GitOrigin-RevId: de39f19a4e61c6e9c76ddc2c65d2ac130a4b7f88
This commit is contained in:
willwang-openai
2026-09-01 18:35:00 +00:00
committed by copyberry
parent 1f4c47343a
commit ef76e6ac30
5 changed files with 515 additions and 234 deletions
@@ -4,13 +4,15 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_analytics::PluginInstallSource;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginSharePrincipalRole;
use codex_app_server_protocol::PluginShareTargetRole;
use codex_config::types::McpServerConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginListBackgroundTaskOptions;
use codex_core_plugins::PluginMarketplaceContext;
use codex_core_plugins::RemotePluginInstallRequest;
use codex_core_plugins::RemotePluginOperationError;
use codex_core_plugins::RemotePluginOperationErrorKind;
use codex_core_plugins::is_openai_curated_marketplace_name;
use codex_core_plugins::loader::load_configured_plugin_mcp_servers;
use codex_core_plugins::manifest::is_agent_plugin_manifest;
@@ -1553,173 +1555,62 @@ impl PluginRequestProcessor {
install_attempt_id: Option<String>,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = remote_plugin_service_config(&config);
let remote_detail =
codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
)
.await
.map_err(|err| {
let error_type = remote_plugin_catalog_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&remote_marketplace_name,
/*plugin_id*/ None,
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_catalog_error_to_jsonrpc(
err,
"read remote plugin details before install",
)
})?;
let actual_remote_marketplace_name = remote_detail.marketplace_name.clone();
let remote_plugin_name = remote_detail.summary.name.clone();
let resolved_plugin_id = PluginId::parse(&remote_detail.summary.id).map_err(|err| {
internal_error(format!(
"invalid resolved plugin id `{}`: {err}",
remote_detail.summary.id
))
})?;
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
let error_message = format!("remote plugin {remote_plugin_id} is disabled by admin");
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
"remote_plugin_not_available",
Some("disabled_by_admin".to_string()),
error_message.clone(),
);
return Err(invalid_request(error_message));
}
if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable {
let error_message =
format!("remote plugin {remote_plugin_id} is not available for install");
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
"remote_plugin_not_available",
Some("install_policy_not_available".to_string()),
error_message.clone(),
);
return Err(invalid_request(error_message));
}
let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle(
&remote_plugin_id,
&actual_remote_marketplace_name,
&remote_plugin_name,
remote_detail.release_version.as_deref(),
remote_detail.bundle_download_url.as_deref(),
remote_detail.app_manifest.clone(),
)
.map_err(|err| {
let error_type = remote_plugin_bundle_install_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_bundle_install_error_to_jsonrpc(err)
})?;
// Direct install writes the same cache tree that installed-plugin sync prunes. Hold the
// shared cache-root gate through the backend mutation so a full sync cannot commit a
// snapshot fetched before this plugin became installed.
let plugins_manager = self.thread_manager.plugins_manager();
let remote_plugin_sync_guard = plugins_manager
.acquire_remote_installed_plugin_sync_guard()
let installation = plugins_manager
.install_remote_plugin(
&config.plugins_config_input(),
auth.as_ref(),
RemotePluginInstallRequest {
marketplace_name: remote_marketplace_name.clone(),
remote_plugin_id: remote_plugin_id.clone(),
install_attempt_id,
},
Some(self.effective_plugins_changed_callback()),
)
.await
.map_err(|err| {
internal_error(format!("failed to coordinate remote plugin install: {err}"))
let classification = match err.kind.as_ref() {
RemotePluginOperationErrorKind::Catalog { source, .. } => Some((
remote_plugin_catalog_error_type(source),
source.sub_error_type(),
)),
RemotePluginOperationErrorKind::Bundle(source) => Some((
remote_plugin_bundle_install_error_type(source),
source.sub_error_type(),
)),
RemotePluginOperationErrorKind::DisabledByAdmin(_) => Some((
"remote_plugin_not_available",
Some("disabled_by_admin".to_string()),
)),
RemotePluginOperationErrorKind::NotAvailable(_) => Some((
"remote_plugin_not_available",
Some("install_policy_not_available".to_string()),
)),
RemotePluginOperationErrorKind::Sync { .. }
| RemotePluginOperationErrorKind::InvalidRequest(_)
| RemotePluginOperationErrorKind::Internal(_) => None,
};
if let Some((error_type, sub_error_type)) = classification {
let marketplace = err
.plugin_id
.as_ref()
.map(|id| id.marketplace_name.as_str())
.unwrap_or(&remote_marketplace_name);
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
marketplace,
err.plugin_id.as_ref(),
error_type,
sub_error_type,
err.to_string(),
);
}
remote_plugin_operation_error_to_jsonrpc(err)
})?;
// Keep this marker through downstream setup after releasing the shared gate. A new sync
// may start then, but it must not prune the bundle that this install just materialized.
let _remote_plugin_cache_mutation =
codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight(
config.codex_home.as_path(),
&actual_remote_marketplace_name,
&remote_plugin_name,
);
let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle(
&remote_plugin_service_config,
config.codex_home.to_path_buf(),
validated_bundle,
)
.await
.map_err(|err| {
let error_type = remote_plugin_bundle_install_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_bundle_install_error_to_jsonrpc(err)
})?;
// Cache first so a backend install cannot succeed when local materialization fails.
// If this backend call fails, the cache entry is harmless because remote installed state
// is still backend-gated.
let install_result = if let Some(install_attempt_id) = install_attempt_id.as_deref() {
codex_core_plugins::remote::install_remote_plugin_with_install_attempt_id(
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&remote_plugin_id,
install_attempt_id,
)
.await
} else {
codex_core_plugins::remote::install_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&remote_plugin_id,
)
.await
}
.map_err(|err| {
let error_type = remote_plugin_catalog_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&result.plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin")
})?;
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback()),
);
drop(remote_plugin_sync_guard);
// Retain the installation outcome through OAuth/app setup: it protects the bundle from pruning.
let remote_detail = installation.detail;
let result = installation.installed;
let plugin_metadata = self
.thread_manager
@@ -1753,7 +1644,7 @@ impl PluginRequestProcessor {
let is_chatgpt_auth = auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth);
let apps_needing_auth = if let Some(app_ids_needing_auth) =
install_result.app_ids_needing_auth
installation.app_ids_needing_auth
{
if app_ids_needing_auth.is_empty()
|| !config.features.apps_enabled_for_auth(is_chatgpt_auth)
@@ -2136,78 +2027,30 @@ impl PluginRequestProcessor {
plugin_id: String,
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request("remote plugin uninstall is not enabled"));
}
validate_remote_plugin_id(&plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = remote_plugin_service_config(&config);
let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target(
&remote_plugin_service_config,
auth.as_ref(),
&plugin_id,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "resolve remote plugin before uninstall")
})?;
let plugins_manager = self.thread_manager.plugins_manager();
let mut plugin_telemetry = plugins_manager
.telemetry_metadata_for_installed_plugin_with_remote_id(
&uninstall_target.plugin_id,
&uninstall_target.remote_plugin_id,
)
.await;
if plugin_telemetry.capability_summary.is_none() {
plugin_telemetry.capability_summary =
Some(uninstall_target.fallback_capability_summary.clone());
}
let remote_plugin_sync_guard = plugins_manager
.acquire_remote_installed_plugin_sync_guard()
.await
.map_err(|err| {
internal_error(format!(
"failed to coordinate remote plugin uninstall: {err}"
))
})?;
let remote_plugin_cache_mutation =
codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight(
config.codex_home.as_path(),
&uninstall_target.plugin_id.marketplace_name,
&uninstall_target.plugin_id.plugin_name,
);
let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.to_path_buf(),
uninstall_target,
)
.await;
let mut refresh_effective_plugins = false;
if matches!(
&uninstall_result,
Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_))
) {
self.analytics_events_client
.track_plugin_uninstalled(plugin_telemetry);
refresh_effective_plugins = plugins_manager.clear_remote_installed_plugins_cache();
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
let outcome = self
.thread_manager
.plugins_manager()
.uninstall_remote_plugin(
&config.plugins_config_input(),
auth.clone(),
auth.as_ref(),
&plugin_id,
Some(self.effective_plugins_changed_callback()),
);
}
drop(remote_plugin_cache_mutation);
drop(remote_plugin_sync_guard);
if refresh_effective_plugins {
)
.await
.map_err(remote_plugin_operation_error_to_jsonrpc)?;
self.analytics_events_client
.track_plugin_uninstalled(outcome.telemetry);
if outcome.effective_plugins_changed {
self.on_effective_plugins_changed().await;
}
if let Some(err) = outcome.cache_removal_error {
return Err(remote_plugin_catalog_error_to_jsonrpc(
err,
"uninstall remote plugin",
));
}
uninstall_result.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin")
})?;
Ok(PluginUninstallResponse {})
}
}
@@ -2528,3 +2371,21 @@ fn remote_plugin_bundle_install_error_to_jsonrpc(
) -> JSONRPCErrorError {
internal_error(format!("install remote plugin bundle: {err}"))
}
fn remote_plugin_operation_error_to_jsonrpc(err: RemotePluginOperationError) -> JSONRPCErrorError {
match *err.kind {
RemotePluginOperationErrorKind::Catalog { context, source } => {
remote_plugin_catalog_error_to_jsonrpc(source, context)
}
RemotePluginOperationErrorKind::Bundle(source) => {
remote_plugin_bundle_install_error_to_jsonrpc(source)
}
RemotePluginOperationErrorKind::Sync { context, source } => {
internal_error(format!("{context}: {source}"))
}
err @ (RemotePluginOperationErrorKind::DisabledByAdmin(_)
| RemotePluginOperationErrorKind::NotAvailable(_)
| RemotePluginOperationErrorKind::InvalidRequest(_)) => invalid_request(err.to_string()),
RemotePluginOperationErrorKind::Internal(message) => internal_error(message),
}
}