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:22:02 +00:00
committed by copyberry
parent 1f4c47343a
commit ef76e6ac30
5 changed files with 515 additions and 234 deletions

View File

@@ -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),
}
}

View File

@@ -83,7 +83,11 @@ pub use manager::PluginUninstallError;
pub use manager::PluginsConfigInput;
pub use manager::PluginsManager;
pub use manager::RecommendedPluginCandidatesInput;
pub use manager::RemoteInstalledPluginSyncGuard;
pub use manager::RemotePluginInstallOutcome;
pub use manager::RemotePluginInstallRequest;
pub use manager::RemotePluginOperationError;
pub use manager::RemotePluginOperationErrorKind;
pub use manager::RemotePluginUninstallOutcome;
pub use marketplace_policy::allowed_configured_marketplace_names;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;

View File

@@ -1,5 +1,12 @@
#[path = "bundled_plugin_exclusions.rs"]
mod bundled_plugin_exclusions;
#[path = "remote_mutations.rs"]
mod remote_mutations;
pub use remote_mutations::RemotePluginInstallOutcome;
pub use remote_mutations::RemotePluginInstallRequest;
pub use remote_mutations::RemotePluginOperationError;
pub use remote_mutations::RemotePluginOperationErrorKind;
pub use remote_mutations::RemotePluginUninstallOutcome;
#[path = "marketplace_context.rs"]
mod marketplace_context;
pub use marketplace_context::PluginMarketplaceContext;
@@ -255,7 +262,7 @@ enum RemoteInstalledPluginsCachePublication {
///
/// Callers should keep this guard only while mutating the remote plugin cache and backend
/// installed state, then release it before unrelated analytics, OAuth, or runtime refresh work.
pub struct RemoteInstalledPluginSyncGuard {
struct RemoteInstalledPluginSyncGuard {
_permit: OwnedSemaphorePermit,
}
@@ -1458,7 +1465,7 @@ impl PluginsManager {
});
}
pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
self: &Arc<Self>,
config: &PluginsConfigInput,
auth: Option<CodexAuth>,
@@ -1563,7 +1570,7 @@ impl PluginsManager {
/// Acquires the cache-root gate shared by full installed-bundle sync, reconciliation, and
/// direct remote plugin mutations.
pub async fn acquire_remote_installed_plugin_sync_guard(
async fn acquire_remote_installed_plugin_sync_guard(
&self,
) -> Result<RemoteInstalledPluginSyncGuard, RemoteInstalledPluginBundleSyncError> {
let permit = tokio::time::timeout(

View File

@@ -0,0 +1,298 @@
//! Shared remote plugin mutations for the CLI and app-server.
//! The sync gate covers cache/backend changes and refresh scheduling. An install outcome keeps
//! its bundle protected from pruning until the caller finishes downstream setup and drops it.
use super::EffectivePluginsChangedCallback;
use super::PluginInstallOutcome;
use super::PluginsConfigInput;
use super::PluginsManager;
use crate::marketplace::MarketplacePluginAuthPolicy;
use crate::remote;
use crate::remote::RemoteInstalledPluginBundleSyncError;
use crate::remote::RemotePluginCacheMutationGuard;
use crate::remote::RemotePluginCatalogError;
use crate::remote::RemotePluginDetail;
use crate::remote_bundle;
use crate::remote_bundle::RemotePluginBundleInstallError;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_login::CodexAuth;
use codex_plugin::PluginId;
use codex_plugin::PluginTelemetryMetadata;
use std::sync::Arc;
#[cfg(test)]
#[path = "remote_mutations_tests.rs"]
mod tests;
pub struct RemotePluginInstallRequest {
pub marketplace_name: String,
pub remote_plugin_id: String,
pub install_attempt_id: Option<String>,
}
/// Keep this outcome alive throughout post-install setup to protect the installed bundle.
#[must_use]
pub struct RemotePluginInstallOutcome {
pub installed: PluginInstallOutcome,
pub detail: RemotePluginDetail,
pub app_ids_needing_auth: Option<Vec<String>>,
_cache_mutation: RemotePluginCacheMutationGuard,
}
pub struct RemotePluginUninstallOutcome {
pub telemetry: PluginTelemetryMetadata,
pub effective_plugins_changed: bool,
/// The backend uninstall succeeded even when removing the local cache failed.
pub cache_removal_error: Option<RemotePluginCatalogError>,
}
#[derive(Debug, thiserror::Error)]
#[error("{kind}")]
pub struct RemotePluginOperationError {
pub plugin_id: Option<PluginId>,
#[source]
pub kind: Box<RemotePluginOperationErrorKind>,
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginOperationErrorKind {
#[error("{context}: {source}")]
Catalog {
context: &'static str,
source: RemotePluginCatalogError,
},
#[error("install remote plugin bundle: {0}")]
Bundle(#[source] RemotePluginBundleInstallError),
#[error("{context}: {source}")]
Sync {
context: &'static str,
source: RemoteInstalledPluginBundleSyncError,
},
#[error("remote plugin {0} is disabled by admin")]
DisabledByAdmin(String),
#[error("remote plugin {0} is not available for install")]
NotAvailable(String),
#[error("{0}")]
InvalidRequest(String),
#[error("{0}")]
Internal(String),
}
impl PluginsManager {
/// Resolve, validate, materialize, and install a remote plugin as one coordinated mutation.
pub async fn install_remote_plugin(
self: &Arc<Self>,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
request: RemotePluginInstallRequest,
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
) -> Result<RemotePluginInstallOutcome, RemotePluginOperationError> {
use RemotePluginOperationErrorKind as Error;
let unresolved = |kind| RemotePluginOperationError {
plugin_id: None,
kind: Box::new(kind),
};
let RemotePluginInstallRequest {
marketplace_name,
remote_plugin_id,
install_attempt_id,
} = request;
if !config.plugins_enabled {
return Err(unresolved(Error::InvalidRequest(format!(
"remote plugin install is not enabled for marketplace {marketplace_name}"
))));
}
remote::validate_remote_plugin_id(&remote_plugin_id)
.map_err(|err| unresolved(Error::InvalidRequest(err.message)))?;
let service = config.remote_plugin_service_config();
let detail = remote::fetch_remote_plugin_detail_with_download_urls(
&service,
auth,
&marketplace_name,
&remote_plugin_id,
)
.await
.map_err(|source| {
unresolved(Error::Catalog {
context: "read remote plugin details before install",
source,
})
})?;
let plugin_id = PluginId::parse(&detail.summary.id).map_err(|err| {
unresolved(Error::Internal(format!(
"invalid resolved plugin id `{}`: {err}",
detail.summary.id
)))
})?;
let resolved = |kind| RemotePluginOperationError {
plugin_id: Some(plugin_id.clone()),
kind: Box::new(kind),
};
if detail.summary.availability == PluginAvailability::DisabledByAdmin {
return Err(resolved(Error::DisabledByAdmin(remote_plugin_id)));
}
if detail.summary.install_policy == PluginInstallPolicy::NotAvailable {
return Err(resolved(Error::NotAvailable(remote_plugin_id)));
}
let bundle = remote_bundle::validate_remote_plugin_bundle(
&remote_plugin_id,
&detail.marketplace_name,
&detail.summary.name,
detail.release_version.as_deref(),
detail.bundle_download_url.as_deref(),
detail.app_manifest.clone(),
)
.map_err(|err| resolved(Error::Bundle(err)))?;
let _sync_guard = self
.acquire_remote_installed_plugin_sync_guard()
.await
.map_err(|source| {
resolved(Error::Sync {
context: "failed to coordinate remote plugin install",
source,
})
})?;
let cache_mutation = remote::mark_remote_plugin_cache_mutation_in_flight(
&self.codex_home,
&detail.marketplace_name,
&detail.summary.name,
);
// Materialize first: a failed download must never leave a backend installation behind.
let installed = remote_bundle::download_and_install_remote_plugin_bundle(
&service,
self.codex_home.clone(),
bundle,
)
.await
.map_err(|err| resolved(Error::Bundle(err)))?;
let install_result = if let Some(install_attempt_id) = install_attempt_id.as_deref() {
remote::install_remote_plugin_with_install_attempt_id(
&service,
auth,
&detail.marketplace_name,
&remote_plugin_id,
install_attempt_id,
)
.await
} else {
remote::install_remote_plugin(
&service,
auth,
&detail.marketplace_name,
&remote_plugin_id,
)
.await
}
.map_err(|source| {
resolved(Error::Catalog {
context: "install remote plugin",
source,
})
})?;
self.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
config,
auth.cloned(),
on_effective_plugins_changed,
);
Ok(RemotePluginInstallOutcome {
installed: PluginInstallOutcome {
plugin_id: installed.plugin_id,
plugin_version: installed.plugin_version,
installed_path: installed.installed_path,
auth_policy: match detail.summary.auth_policy {
PluginAuthPolicy::OnInstall => MarketplacePluginAuthPolicy::OnInstall,
PluginAuthPolicy::OnUse => MarketplacePluginAuthPolicy::OnUse,
},
},
detail,
app_ids_needing_auth: install_result.app_ids_needing_auth,
_cache_mutation: cache_mutation,
})
}
/// Remove the backend installation and local cache, then schedule an installed-state refresh.
pub async fn uninstall_remote_plugin(
self: &Arc<Self>,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
remote_plugin_id: &str,
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
) -> Result<RemotePluginUninstallOutcome, RemotePluginOperationError> {
use RemotePluginOperationErrorKind as Error;
let unresolved = |kind| RemotePluginOperationError {
plugin_id: None,
kind: Box::new(kind),
};
if !config.plugins_enabled {
return Err(unresolved(Error::InvalidRequest(
"remote plugin uninstall is not enabled".to_string(),
)));
}
remote::validate_remote_plugin_id(remote_plugin_id)
.map_err(|err| unresolved(Error::InvalidRequest(err.message)))?;
let service = config.remote_plugin_service_config();
let target =
remote::resolve_remote_plugin_uninstall_target(&service, auth, remote_plugin_id)
.await
.map_err(|source| {
unresolved(Error::Catalog {
context: "resolve remote plugin before uninstall",
source,
})
})?;
let mut telemetry = self
.telemetry_metadata_for_installed_plugin_with_remote_id(
&target.plugin_id,
remote_plugin_id,
)
.await;
if telemetry.capability_summary.is_none() {
telemetry.capability_summary = Some(target.fallback_capability_summary.clone());
}
let plugin_id = target.plugin_id.clone();
let resolved = |kind| RemotePluginOperationError {
plugin_id: Some(plugin_id.clone()),
kind: Box::new(kind),
};
let _sync_guard = self
.acquire_remote_installed_plugin_sync_guard()
.await
.map_err(|source| {
resolved(Error::Sync {
context: "failed to coordinate remote plugin uninstall",
source,
})
})?;
let _cache_mutation = remote::mark_remote_plugin_cache_mutation_in_flight(
&self.codex_home,
&plugin_id.marketplace_name,
&plugin_id.plugin_name,
);
let cache_removal_error =
match remote::uninstall_remote_plugin(&service, auth, self.codex_home.clone(), target)
.await
{
Ok(()) => None,
Err(err @ RemotePluginCatalogError::CacheRemove(_)) => Some(err),
Err(source) => {
return Err(resolved(Error::Catalog {
context: "uninstall remote plugin",
source,
}));
}
};
let effective_plugins_changed = self.clear_remote_installed_plugins_cache();
self.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
config,
auth.cloned(),
on_effective_plugins_changed,
);
Ok(RemotePluginUninstallOutcome {
telemetry,
effective_plugins_changed,
cache_removal_error,
})
}
}

View File

@@ -0,0 +1,111 @@
//! Regression coverage for the shared mutation gate, refresh, and backend failure handling.
use super::*;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::test_support::load_plugins_config;
use crate::test_support::test_auth_manager;
use crate::test_support::test_plugins_manager_with_auth_manager;
use crate::test_support::write_file;
use codex_protocol::auth::AuthMode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use tokio::sync::Notify;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn uninstall_serializes_backend_mutations_and_preserves_cache_on_failure() {
for uninstall_fails in [false, true] {
let home = TempDir::new().unwrap();
let server = MockServer::start().await;
let mut config = load_plugins_config(home.path(), 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().await;
let manager = Arc::new(test_plugins_manager_with_auth_manager(
home.path().to_path_buf(),
/*restriction_product*/ None,
auth_manager,
));
let remote_id = "b1234567-89ab-4cde-8f01-234567890abc";
let plugin_id = PluginId::new(
"sample".to_string(),
REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(),
)
.unwrap();
let cache = manager.store.plugin_base_root(&plugin_id);
write_file(
cache.join("1.0.0/.codex-plugin/plugin.json").as_path(),
r#"{"name":"sample","version":"1.0.0"}"#,
);
Mock::given(method("GET"))
.and(path(format!("/backend-api/ps/plugins/{remote_id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": remote_id, "name": "sample", "scope": "GLOBAL",
"installation_policy": "AVAILABLE", "authentication_policy": "ON_USE",
"release": {"display_name": "Sample", "description": "Sample plugin", "interface": {}}
}))).mount(&server).await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"plugins": [], "pagination": {"next_page_token": null}})),
)
.expect(if uninstall_fails { 0 } else { 1 })
.mount(&server)
.await;
let gate_held = Arc::new(AtomicBool::new(false));
let gate_held_at_post = Arc::clone(&gate_held);
let manager_at_post = Arc::clone(&manager);
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/ps/plugins/{remote_id}/uninstall"
)))
.respond_with(move |_: &wiremock::Request| {
gate_held_at_post.store(
manager_at_post
.remote_installed_plugin_bundle_sync_gate
.available_permits()
== 0,
Ordering::SeqCst,
);
ResponseTemplate::new(if uninstall_fails { 400 } else { 200 })
.set_body_json(json!({"id": remote_id, "enabled": false}))
})
.expect(1)
.mount(&server)
.await;
let refreshed = Arc::new(Notify::new());
let refreshed_callback = Arc::clone(&refreshed);
let outcome = manager
.uninstall_remote_plugin(
&config,
auth.as_ref(),
remote_id,
Some(Arc::new(move |_| refreshed_callback.notify_one())),
)
.await;
assert_eq!(
(
gate_held.load(Ordering::SeqCst),
manager
.remote_installed_plugin_bundle_sync_gate
.available_permits(),
cache.as_path().exists(),
outcome.is_err()
),
(true, 1, uninstall_fails, uninstall_fails),
);
if !uninstall_fails {
tokio::time::timeout(std::time::Duration::from_secs(5), refreshed.notified())
.await
.expect("successful uninstall should refresh installed state");
}
}
}