diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 9de2c184b98d..500d18d4a2a0 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -6,7 +6,7 @@ use codex_core_plugins::PluginsManager; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; -use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_add::add_marketplace_for_config; use codex_core_plugins::marketplace_add::is_local_marketplace_source; use codex_external_agent_migration::build_mcp_config_from_external; use codex_external_agent_migration::count_missing_commands; @@ -701,6 +701,35 @@ impl ExternalAgentConfigService { }; let mut outcome = PluginImportOutcome::default(); let plugins_manager = PluginsManager::new(self.codex_home.clone()); + let config = ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .fallback_cwd(Some(self.codex_home.clone())) + .build() + .await + .map_err(|err| { + invalid_data_error(format!("failed to load config before plugin import: {err}")) + })?; + let plugin_marketplace_requirements = config + .config_layer_stack + .requirements() + .plugin_marketplaces + .as_ref(); + if plugin_marketplace_requirements + .is_some_and(|requirements| !requirements.value.allows_user_additions()) + { + for plugin_group in plugins { + let marketplace_name = plugin_group.marketplace_name; + outcome.failed_plugin_ids.extend( + plugin_group + .plugin_names + .into_iter() + .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")), + ); + outcome.failed_marketplaces.push(marketplace_name); + } + return Ok(outcome); + } + let plugins_input = config.plugins_config_input(); for plugin_group in plugins { let marketplace_name = plugin_group.marketplace_name.clone(); let plugin_names = plugin_group.plugin_names; @@ -708,6 +737,13 @@ impl ExternalAgentConfigService { .iter() .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) .collect::>(); + if plugin_marketplace_requirements.is_some_and(|requirements| { + !requirements.value.allows_marketplace(&marketplace_name) + }) { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + } let source_settings = cwd.map_or_else( || self.external_agent_home.join("settings.json"), |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), @@ -728,7 +764,12 @@ impl ExternalAgentConfigService { ref_name: import_source.ref_name, sparse_paths: Vec::new(), }; - let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await; + let add_marketplace_outcome = add_marketplace_for_config( + &config.config_layer_stack, + self.codex_home.clone(), + request, + ) + .await; let marketplace_path = match add_marketplace_outcome { Ok(add_marketplace_outcome) => { let Some(marketplace_path) = find_marketplace_manifest_path( @@ -751,10 +792,13 @@ impl ExternalAgentConfigService { }; for plugin_name in plugin_names { match plugins_manager - .install_plugin(PluginInstallRequest { - plugin_name: plugin_name.clone(), - marketplace_path: marketplace_path.clone(), - }) + .install_plugin_for_config( + &plugins_input, + PluginInstallRequest { + plugin_name: plugin_name.clone(), + marketplace_path: marketplace_path.clone(), + }, + ) .await { Ok(_) => outcome diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 84e16f726d98..9957554a988b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -299,7 +299,7 @@ use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; use codex_core_plugins::marketplace_add::MarketplaceAddError; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; -use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home; +use codex_core_plugins::marketplace_add::add_marketplace_for_config as add_marketplace_to_codex_home; use codex_core_plugins::marketplace_remove::MarketplaceRemoveError; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; diff --git a/codex-rs/app-server/src/request_processors/marketplace_processor.rs b/codex-rs/app-server/src/request_processors/marketplace_processor.rs index 1a095074180b..f85a33e3be69 100644 --- a/codex-rs/app-server/src/request_processors/marketplace_processor.rs +++ b/codex-rs/app-server/src/request_processors/marketplace_processor.rs @@ -105,7 +105,9 @@ impl MarketplaceRequestProcessor { &self, params: MarketplaceAddParams, ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; add_marketplace_to_codex_home( + &config.config_layer_stack, self.config.codex_home.to_path_buf(), MarketplaceAddRequest { source: params.source, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index c32829079fb7..f57a72a260c1 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -175,6 +175,15 @@ fn plugin_share_principal_from_remote( } } +fn remote_marketplace_is_allowed(config: &Config, marketplace_name: &str) -> bool { + config + .config_layer_stack + .requirements() + .plugin_marketplaces + .as_ref() + .is_none_or(|requirements| requirements.value.allows_marketplace(marketplace_name)) +} + impl PluginRequestProcessor { pub(crate) fn new( auth_manager: Arc, @@ -485,6 +494,9 @@ impl PluginRequestProcessor { Ok(remote_marketplaces) => { for remote_marketplace in remote_marketplaces .into_iter() + .filter(|marketplace| { + remote_marketplace_is_allowed(&config, &marketplace.name) + }) .map(remote_marketplace_to_info) { if let Some(existing) = data @@ -620,6 +632,11 @@ impl PluginRequestProcessor { } } Err(remote_marketplace_name) => { + if !remote_marketplace_is_allowed(&config, &remote_marketplace_name) { + return Err(invalid_request(format!( + "remote marketplace {remote_marketplace_name} is not allowed by managed requirements" + ))); + } if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin read is not enabled for marketplace {remote_marketplace_name}" @@ -640,6 +657,12 @@ impl PluginRequestProcessor { .map_err(|err| { remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details") })?; + if !remote_marketplace_is_allowed(&config, &remote_detail.marketplace_name) { + return Err(invalid_request(format!( + "remote marketplace {} is not allowed by managed requirements", + remote_detail.marketplace_name + ))); + } let plugin_apps = remote_detail .app_ids .iter() @@ -667,6 +690,11 @@ impl PluginRequestProcessor { } = params; let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !remote_marketplace_is_allowed(&config, &remote_marketplace_name) { + return Err(invalid_request(format!( + "remote marketplace {remote_marketplace_name} is not allowed by managed requirements" + ))); + } if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin skill read is not enabled for marketplace {remote_marketplace_name}" @@ -683,6 +711,20 @@ impl PluginRequestProcessor { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; + let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details"))?; + if !remote_marketplace_is_allowed(&config, &remote_detail.marketplace_name) { + return Err(invalid_request(format!( + "remote marketplace {} is not allowed by managed requirements", + remote_detail.marketplace_name + ))); + } let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( &remote_plugin_service_config, auth.as_ref(), @@ -888,13 +930,14 @@ impl PluginRequestProcessor { } let plugins_manager = self.thread_manager.plugins_manager(); + let plugins_input = config.plugins_config_input(); let request = PluginInstallRequest { plugin_name, marketplace_path, }; let result = plugins_manager - .install_plugin(request) + .install_plugin_for_config(&plugins_input, request) .await .map_err(Self::plugin_install_error)?; let config = match self.load_latest_config(config_cwd).await { @@ -938,6 +981,11 @@ impl PluginRequestProcessor { remote_plugin_id: String, ) -> Result { let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !remote_marketplace_is_allowed(&config, &remote_marketplace_name) { + return Err(invalid_request(format!( + "remote marketplace {remote_marketplace_name} is not allowed by managed requirements" + ))); + } if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin install is not enabled for marketplace {remote_marketplace_name}" @@ -963,6 +1011,12 @@ impl PluginRequestProcessor { "read remote plugin details before install", ) })?; + if !remote_marketplace_is_allowed(&config, &remote_detail.marketplace_name) { + return Err(invalid_request(format!( + "remote marketplace {} is not allowed by managed requirements", + remote_detail.marketplace_name + ))); + } if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { let remote_plugin_id = &remote_detail.summary.id; return Err(invalid_request(format!( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 53a3b3d296d5..9c944001ce84 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -487,6 +487,46 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable "plugin_release_skill_id": "skill-1", "skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan." }"##; + let detail_body = r#"{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) + .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; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; Mock::given(method("GET")) .and(path( diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index fcd9049d59e5..e7072042c388 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -7,7 +7,7 @@ use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; -use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_add::add_marketplace_for_config; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; @@ -72,7 +72,7 @@ impl MarketplaceCli { .map_err(anyhow::Error::msg)?; match subcommand { - MarketplaceSubcommand::Add(args) => run_add(args).await?, + MarketplaceSubcommand::Add(args) => run_add(overrides, args).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -81,15 +81,19 @@ impl MarketplaceCli { } } -async fn run_add(args: AddMarketplaceArgs) -> Result<()> { +async fn run_add(overrides: Vec<(String, toml::Value)>, args: AddMarketplaceArgs) -> Result<()> { let AddMarketplaceArgs { source, ref_name, sparse_paths, } = args; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; - let outcome = add_marketplace( + let outcome = add_marketplace_for_config( + &config.config_layer_stack, codex_home.to_path_buf(), MarketplaceAddRequest { source, diff --git a/codex-rs/core-plugins/src/marketplace_add.rs b/codex-rs/core-plugins/src/marketplace_add.rs index 927d337d2492..af068d8f47ef 100644 --- a/codex-rs/core-plugins/src/marketplace_add.rs +++ b/codex-rs/core-plugins/src/marketplace_add.rs @@ -1,5 +1,6 @@ use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::marketplace_install_root; +use codex_config::ConfigLayerStack; use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; use std::path::Path; @@ -56,6 +57,25 @@ pub async fn add_marketplace( .map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))? } +pub async fn add_marketplace_for_config( + config_layer_stack: &ConfigLayerStack, + codex_home: PathBuf, + request: MarketplaceAddRequest, +) -> Result { + if !config_layer_stack + .requirements() + .plugin_marketplaces + .as_ref() + .is_none_or(|requirements| requirements.value.allows_user_additions()) + { + return Err(MarketplaceAddError::InvalidRequest( + "marketplace additions are disabled by managed requirements".to_string(), + )); + } + + add_marketplace(codex_home, request).await +} + pub fn is_local_marketplace_source( source: &str, explicit_ref: Option, @@ -213,6 +233,13 @@ where mod tests { use super::*; use anyhow::Result; + use codex_app_server_protocol::ConfigLayerSource; + use codex_config::ConfigLayerEntry; + use codex_config::ConfigRequirements; + use codex_config::ConfigRequirementsToml; + use codex_config::PluginMarketplaceRequirementsToml; + use codex_config::RequirementSource; + use codex_config::Sourced; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -253,6 +280,48 @@ mod tests { Ok(()) } + #[tokio::test] + async fn add_marketplace_for_config_rejects_managed_user_addition_block() -> Result<()> { + let tmp = tempfile::tempdir()?; + let config_path = AbsolutePathBuf::try_from(tmp.path().join("config.toml"))?; + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + toml::Value::Table(toml::map::Map::new()), + )], + ConfigRequirements { + plugin_marketplaces: Some(Sourced::new( + PluginMarketplaceRequirementsToml { + allowed_names: None, + allow_user_additions: Some(false), + }, + RequirementSource::Unknown, + )), + ..Default::default() + }, + ConfigRequirementsToml::default(), + )?; + + let err = add_marketplace_for_config( + &config_layer_stack, + tmp.path().to_path_buf(), + MarketplaceAddRequest { + source: "owner/repo".to_string(), + ref_name: None, + sparse_paths: Vec::new(), + }, + ) + .await + .expect_err("managed requirements should block user marketplace additions"); + + assert!(matches!( + err, + MarketplaceAddError::InvalidRequest(ref message) + if message == "marketplace additions are disabled by managed requirements" + )); + Ok(()) + } + #[test] fn add_marketplace_sync_installs_local_directory_source_and_updates_config() -> Result<()> { let codex_home = TempDir::new()?;