From a8d7eff801e49c10101a7f0ab6057e6ff89823f3 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Fri, 29 May 2026 20:27:01 -0700 Subject: [PATCH] Handle partial remote installed plugin failures --- .../app-server/tests/suite/v2/plugin_list.rs | 145 ++++++++++++++++++ codex-rs/core-plugins/src/remote.rs | 41 ++++- 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 93dbfb4773..7b60062679 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -2226,6 +2226,140 @@ plugin_sharing = true Ok(()) } +#[tokio::test] +async fn plugin_installed_keeps_global_remote_plugins_when_workspace_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins_error(&server, "WORKSPACE").await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: PluginInstalledResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "openai-curated-remote"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![("linear@openai-curated-remote".to_string(), true, true)] + ); + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_keeps_workspace_remote_plugins_when_global_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = false +plugin_sharing = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ); + mount_remote_installed_plugins_error(&server, "GLOBAL").await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: PluginInstalledResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "workspace-shared-with-me"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![( + "shared-linear@workspace-shared-with-me".to_string(), + true, + true + )] + ); + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + Ok(()) +} + #[tokio::test] async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> { let codex_home = TempDir::new()?; @@ -3049,6 +3183,17 @@ async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: .await; } +async fn mount_remote_installed_plugins_error(server: &MockServer, scope: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(500).set_body_string("remote installed fetch failed")) + .mount(server) + .await; +} + fn empty_remote_installed_plugins_body() -> &'static str { r#"{ "plugins": [], diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 16ae512931..de846aaf7e 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -689,12 +689,41 @@ pub(crate) async fn fetch_remote_installed_plugins( Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) }; - let (global, workspace) = tokio::try_join!(global, workspace)?; - let mut installed_plugins = [global, workspace] - .into_iter() - .flat_map(|(_scope, plugins)| plugins) - .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) - .collect::, _>>()?; + let (global, workspace) = tokio::join!(global, workspace); + + let mut installed_plugins = Vec::new(); + let mut first_error = None; + let mut any_scope_succeeded = false; + for (scope, result) in [ + (RemotePluginScope::Global, global), + (RemotePluginScope::Workspace, workspace), + ] { + match result.and_then(|(_scope, plugins)| { + plugins + .into_iter() + .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) + .collect::, _>>() + }) { + Ok(scope_plugins) => { + any_scope_succeeded = true; + installed_plugins.extend(scope_plugins); + } + Err(err) => { + tracing::warn!( + scope = scope.api_value(), + error = %err, + "failed to fetch remote installed plugins for scope" + ); + if first_error.is_none() { + first_error = Some(err); + } + } + } + } + if !any_scope_succeeded && let Some(err) = first_error { + return Err(err); + } + installed_plugins.sort_by(|left, right| { left.marketplace_name .cmp(&right.marketplace_name)