use std::time::Duration; use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::start_analytics_events_server; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear"; #[tokio::test] async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { let codex_home = TempDir::new()?; write_installed_plugin(&codex_home, "debug", "sample-plugin")?; std::fs::write( codex_home.path().join("config.toml"), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let params = PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), }; let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); assert!( !codex_home .path() .join("plugins/cache/debug/sample-plugin") .exists() ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); let request_id = mcp.send_plugin_uninstall_request(params).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); Ok(()) } #[tokio::test] async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; let codex_home = TempDir::new()?; write_installed_plugin(&codex_home, "debug", "sample-plugin")?; std::fs::write( codex_home.path().join("config.toml"), format!( "chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n", analytics_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 mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); let payload = timeout(DEFAULT_TIMEOUT, async { loop { let Some(requests) = analytics_server.received_requests().await else { tokio::time::sleep(Duration::from_millis(25)).await; continue; }; if let Some(request) = requests.iter().find(|request| { request.method == "POST" && request.url.path() == "/codex/analytics-events/events" }) { break request.body.clone(); } tokio::time::sleep(Duration::from_millis(25)).await; } }) .await?; let payload: serde_json::Value = serde_json::from_slice(&payload).expect("analytics payload"); assert_eq!( payload, json!({ "events": [{ "event_type": "codex_plugin_uninstalled", "event_params": { "plugin_id": "sample-plugin@debug", "plugin_name": "sample-plugin", "marketplace_name": "debug", "has_skills": false, "mcp_server_count": 0, "connector_ids": [], "product_client_id": DEFAULT_CLIENT_NAME, } }] }) ); Ok(()) } #[tokio::test] async fn plugin_uninstall_rejects_remote_plugin_when_remote_plugin_is_disabled() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "plugins~Plugin_sample".to_string(), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(err.error.code, -32600); assert!( err.error .message .contains("remote plugin uninstall is not enabled") ); Ok(()) } #[tokio::test] async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", 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, )?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "GLOBAL").await; Mock::given(method("POST")) .and(path(format!( "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" ))) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( ResponseTemplate::new(200) .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), ) .mount(&server) .await; let remote_plugin_cache_root = codex_home .path() .join("plugins/cache/chatgpt-global/linear"); std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), r#"{"name":"linear","version":"1.0.0"}"#, )?; let legacy_remote_plugin_cache_root = codex_home .path() .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: REMOTE_PLUGIN_ID.to_string(), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); wait_for_remote_plugin_request_count( &server, "POST", &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), /*expected_count*/ 1, ) .await?; assert!(!remote_plugin_cache_root.exists()); assert!(!legacy_remote_plugin_cache_root.exists()); Ok(()) } #[tokio::test] async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", 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, )?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "WORKSPACE").await; Mock::given(method("POST")) .and(path(format!( "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" ))) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( ResponseTemplate::new(200) .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), ) .mount(&server) .await; let workspace_cache_root = codex_home .path() .join("plugins/cache/chatgpt-workspace/linear"); std::fs::create_dir_all(workspace_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( workspace_cache_root.join("1.0.0/.codex-plugin/plugin.json"), r#"{"name":"linear","version":"1.0.0"}"#, )?; let global_cache_root = codex_home .path() .join("plugins/cache/chatgpt-global/linear"); std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: REMOTE_PLUGIN_ID.to_string(), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); wait_for_remote_plugin_request_count( &server, "POST", &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), /*expected_count*/ 1, ) .await?; assert!(!workspace_cache_root.exists()); assert!(global_cache_root.exists()); Ok(()) } #[tokio::test] async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", 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 legacy_remote_plugin_cache_root = codex_home .path() .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: REMOTE_PLUGIN_ID.to_string(), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(err.error.code, -32600); assert!(err.error.message.contains("remote plugin catalog request")); wait_for_remote_plugin_request_count( &server, "GET", &format!("/ps/plugins/{REMOTE_PLUGIN_ID}"), /*expected_count*/ 1, ) .await?; wait_for_remote_plugin_request_count( &server, "POST", &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), /*expected_count*/ 0, ) .await?; assert!(legacy_remote_plugin_cache_root.exists()); Ok(()) } #[tokio::test] async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin".to_string(), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(err.error.code, -32600); assert!(err.error.message.contains("invalid plugin id")); wait_for_remote_plugin_request_count( &server, "POST", "/plugins/sample-plugin/uninstall", /*expected_count*/ 0, ) .await?; Ok(()) } #[tokio::test] async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "linear/../../oops".to_string(), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(err.error.code, -32600); assert!(err.error.message.contains("invalid plugin id")); wait_for_remote_plugin_request_count( &server, "POST", "/plugins/linear/../../oops/uninstall", /*expected_count*/ 0, ) .await?; Ok(()) } #[tokio::test] async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: String::new(), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(err.error.code, -32600); assert!(err.error.message.contains("invalid plugin id")); Ok(()) } fn write_installed_plugin( codex_home: &TempDir, marketplace_name: &str, plugin_name: &str, ) -> Result<()> { let plugin_root = codex_home .path() .join("plugins/cache") .join(marketplace_name) .join(plugin_name) .join("local/.codex-plugin"); std::fs::create_dir_all(&plugin_root)?; std::fs::write( plugin_root.join("plugin.json"), format!(r#"{{"name":"{plugin_name}"}}"#), )?; Ok(()) } fn write_remote_plugin_catalog_config( codex_home: &std::path::Path, base_url: &str, ) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), format!( r#" chatgpt_base_url = "{base_url}" [features] plugins = true remote_plugin = true "# ), ) } async fn mount_remote_plugin_detail( server: &MockServer, remote_plugin_id: &str, release_version: &str, scope: &str, ) { let detail_body = format!( r#"{{ "id": "{remote_plugin_id}", "name": "linear", "scope": "{scope}", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": {{ "version": "{release_version}", "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], "interface": {{ "short_description": "Plan and track work" }}, "skills": [] }} }}"# ); Mock::given(method("GET")) .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) .mount(server) .await; } async fn wait_for_remote_plugin_request_count( server: &MockServer, method_name: &str, path_suffix: &str, expected_count: usize, ) -> Result<()> { timeout(DEFAULT_TIMEOUT, async { loop { let Some(requests) = server.received_requests().await else { if expected_count == 0 { return Ok::<(), anyhow::Error>(()); } bail!("wiremock did not record requests"); }; let request_count = requests .iter() .filter(|request| { request.method == method_name && request.url.path().ends_with(path_suffix) }) .count(); if request_count == expected_count { return Ok::<(), anyhow::Error>(()); } if request_count > expected_count { bail!( "expected exactly {expected_count} {method_name} {path_suffix} requests, got {request_count}" ); } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await??; Ok(()) }