diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 38278a68eb..108afa006e 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -69,6 +69,7 @@ use crate::remote_plugin_id_resolver::RemoteInstalledPluginsSnapshot; use crate::remote_plugin_id_resolver::RemotePluginIdResolver; use crate::remote_plugin_id_resolver::persisted_remote_plugin_id_for_installation; use crate::skill_snapshots::new_plugin_skill_snapshots; +use crate::startup_sync::OPENAI_PLUGINS_GIT_URL; use crate::startup_sync::curated_plugins_api_marketplace_path; use crate::startup_sync::curated_plugins_repo_path; use crate::startup_sync::read_curated_plugins_sha; @@ -677,7 +678,12 @@ impl PluginsManager { config: &PluginsConfigInput, on_effective_plugins_changed: Option, ) { - if config.plugins_enabled && !self.remote_global_catalog_active(config) { + if config.plugins_enabled + && !self.remote_global_catalog_active(config) + && MarketplacePolicy::from_requirements(config.config_layer_stack.requirements()) + .validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None) + .is_ok() + { self.start_curated_repo_sync( config.http_client_factory.clone(), on_effective_plugins_changed, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 8e1bf449ef..70594e8704 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -75,6 +75,9 @@ use wiremock::matchers::query_param; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; +#[path = "marketplace_policy/curated_loading_tests.rs"] +mod curated_marketplace_policy; + fn unrestricted_config_layer_stack() -> ConfigLayerStack { ConfigLayerStack::default() } diff --git a/codex-rs/core-plugins/src/marketplace_policy.rs b/codex-rs/core-plugins/src/marketplace_policy.rs index b1ec3eb1a5..d5907554fb 100644 --- a/codex-rs/core-plugins/src/marketplace_policy.rs +++ b/codex-rs/core-plugins/src/marketplace_policy.rs @@ -10,6 +10,7 @@ use crate::marketplace::marketplace_root_dir; use crate::marketplace_add::MarketplaceSource; use crate::marketplace_add::parse_marketplace_source; use crate::remote::RemotePluginScope; +use crate::startup_sync::OPENAI_PLUGINS_GIT_URL; use crate::startup_sync::curated_plugins_api_marketplace_path; use crate::startup_sync::curated_plugins_repo_path; use codex_config::ConfigLayerStack; @@ -110,6 +111,9 @@ impl MarketplacePolicy { ) -> Result<(), String> { let root = marketplace_root_dir(marketplace_path).map_err(|err| err.to_string())?; if let Some(expected_name) = managed_marketplace_name(codex_home, marketplace_path, &root) { + if is_openai_curated_marketplace_name(expected_name) { + self.validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None)?; + } return validate_expected_marketplace_name(expected_name, marketplace_name); } if is_reserved_marketplace_name(marketplace_name) { @@ -239,7 +243,12 @@ pub(crate) fn policy_filtered_plugin_config( if configured_marketplace_names.contains(marketplace_name) { return allowed_marketplace_names.contains(marketplace_name); } - is_openai_curated_marketplace_name(marketplace_name) || !policy.is_restricted() + if is_openai_curated_marketplace_name(marketplace_name) { + return policy + .validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None) + .is_ok(); + } + !policy.is_restricted() }); } Some(effective_config) diff --git a/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs b/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs new file mode 100644 index 0000000000..841ebf1d87 --- /dev/null +++ b/codex-rs/core-plugins/src/marketplace_policy/curated_loading_tests.rs @@ -0,0 +1,118 @@ +//! Exercises curated Git requirements across discovery, installation, cached loading, and sync. + +use super::*; +use pretty_assertions::assert_eq; + +#[path = "curated_skill_tests.rs"] +mod skills; + +#[tokio::test] +async fn curated_git_policy_controls_catalog_install_and_cached_activation() { + for (name, auth) in [ + (OPENAI_CURATED_MARKETPLACE_NAME, AuthMode::Chatgpt), + (OPENAI_API_CURATED_MARKETPLACE_NAME, AuthMode::ApiKey), + ] { + let home = TempDir::new().unwrap(); + let root = curated_plugins_repo_path(home.path()); + write_openai_curated_marketplace(&root, &["sample"]); + write_openai_api_curated_marketplace(&root, &["sample"]); + write_curated_plugin_sha(home.path(), TEST_CURATED_PLUGIN_SHA); + write_cached_plugin(home.path(), name, "sample"); + let user_config = format!("[plugins.\"sample@{name}\"]\nenabled = true\n"); + let allowed = plugins_config_input_with_requirements( + home.path(), + &user_config, + r#" +[marketplaces] +restrict_to_allowed_sources = true +[marketplaces.allowed_sources.curated] +source = "git" +url = "https://github.com/openai/plugins.git" +"#, + ); + let blocked = plugins_config_input_with_requirements( + home.path(), + &user_config, + "[marketplaces]\nrestrict_to_allowed_sources = true\n", + ); + let host_allowed = plugins_config_input_with_requirements( + home.path(), + &user_config, + r#" +[marketplaces] +restrict_to_allowed_sources = true +[marketplaces.allowed_sources.github] +source = "host_pattern" +host_pattern = '^github[.]com$' +"#, + ); + let manager = Arc::new(test_plugins_manager_with_options( + home.path().to_path_buf(), + Some(Product::Codex), + Some(auth), + )); + let manifest = if name == OPENAI_CURATED_MARKETPLACE_NAME { + root.join(".agents/plugins/marketplace.json") + } else { + curated_plugins_api_marketplace_path(home.path()) + }; + for (config, permitted) in [(&allowed, true), (&blocked, false), (&host_allowed, true)] { + let expected = if permitted { + vec![format!("sample@{name}")] + } else { + Vec::new() + }; + assert_eq!(loaded_plugin_names(&manager, config).await, expected); + let catalogs = manager + .list_marketplaces_for_config(config, &[], /*include_openai_curated*/ true) + .unwrap(); + assert_eq!( + catalogs + .marketplaces + .iter() + .map(|catalog| catalog.name.as_str()) + .collect::>(), + if permitted { vec![name] } else { Vec::new() } + ); + assert_eq!( + manager + .install_plugin( + &config.config_layer_stack, + PluginInstallRequest { + plugin_name: "sample".to_string(), + marketplace_path: AbsolutePathBuf::try_from(manifest.clone()).unwrap(), + } + ) + .await + .is_ok(), + permitted + ); + } + manager.maybe_start_curated_repo_sync_for_config( + &blocked, /*on_effective_plugins_changed*/ None, + ); + assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst)); + } +} + +#[tokio::test] +async fn blocking_curated_git_does_not_block_remote_installed_plugins() { + let home = TempDir::new().unwrap(); + write_cached_plugin(home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear"); + let manager = test_plugins_manager_with_options( + home.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); + let mut config = plugins_config_input_with_requirements( + home.path(), + "", + "[marketplaces]\nrestrict_to_allowed_sources = true\n", + ); + config.remote_plugin_enabled = true; + assert_eq!( + loaded_plugin_names(&manager, &config).await, + vec!["linear@openai-curated-remote".to_string()] + ); +} diff --git a/codex-rs/core-plugins/src/marketplace_policy/curated_skill_tests.rs b/codex-rs/core-plugins/src/marketplace_policy/curated_skill_tests.rs new file mode 100644 index 0000000000..2dba808d1c --- /dev/null +++ b/codex-rs/core-plugins/src/marketplace_policy/curated_skill_tests.rs @@ -0,0 +1,135 @@ +//! Verifies managed curated Git restrictions through plugin and skill loading. + +use std::fs; + +use crate::PluginsConfigInput; +use crate::PluginsManager; +use crate::test_support::test_skill_root_loader; +use anyhow::Result; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_config::loader::load_config_layers_state; +use codex_exec_server::LOCAL_FS; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use codex_login::CodexAuth; +use codex_login::test_support::auth_manager_from_optional_auth; +use codex_protocol::protocol::Product; +use codex_protocol::protocol::SkillScope; +use codex_skills::LoadedSkills; +use codex_skills::SkillMetadata; +use codex_skills::SkillRootLoadRequest; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[tokio::test] +async fn curated_git_requirements_control_plugin_skills() -> Result<()> { + for (name, allow_curated) in [ + ("openai-curated", true), + ("openai-curated", false), + ("openai-api-curated", true), + ("openai-api-curated", false), + ("openai-bundled", false), + ] { + let home = TempDir::new()?; + let plugin_id = format!("sample@{name}"); + let root = home + .path() + .join(format!("plugins/cache/{name}/sample/local")); + fs::create_dir_all(root.join(".codex-plugin"))?; + fs::write( + root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample","description":"inspect sample data"}"#, + )?; + let skill_dir = root.join("skills/sample-search"); + fs::create_dir_all(&skill_dir)?; + let skill_path = skill_dir.join("SKILL.md"); + fs::write( + &skill_path, + "---\ndescription: inspect sample data\n---\n\n# body\n", + )?; + let mut user_config = format!("[plugins.\"{plugin_id}\"]\nenabled = true\n"); + if name == "openai-bundled" { + let bundled = home.path().join(".tmp/bundled-marketplaces/openai-bundled"); + user_config.push_str(&format!( + "\n[marketplaces.openai-bundled]\nsource_type = \"local\"\nsource = {bundled:?}\n" + )); + } + fs::write(home.path().join("config.toml"), user_config)?; + let requirements_path = home.path().join("requirements.toml"); + let rule = if allow_curated { + "[marketplaces.allowed_sources.curated]\nsource = 'git'\nurl = 'https://github.com/openai/plugins.git'\n" + } else { + "" + }; + fs::write( + &requirements_path, + format!("[marketplaces]\nrestrict_to_allowed_sources = true\n{rule}"), + )?; + let home_path = AbsolutePathBuf::try_from(home.path())?; + let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), + home.path(), + Some(home_path), + &[], + LoaderOverrides { + system_requirements_path: Some(requirements_path), + ..LoaderOverrides::without_managed_config_for_tests() + }, + &NoopThreadConfigLoader, + ) + .await?; + let config = PluginsConfigInput::new( + config_layer_stack, + "openai".to_string(), + /*plugins_enabled*/ true, + /*remote_plugin_enabled*/ false, + "https://chatgpt.com/backend-api/".to_string(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + let auth = if name == "openai-curated" { + CodexAuth::create_dummy_chatgpt_auth_for_testing() + } else { + CodexAuth::from_api_key("test-api-key") + }; + let skills = test_skill_root_loader(); + let manager = PluginsManager::new( + home.path().to_path_buf(), + auth_manager_from_optional_auth(Some(auth)), + skills.clone(), + ); + let plugins = manager.plugins_for_config(&config).await; + let loaded = skills + .load_roots(SkillRootLoadRequest { + roots: plugins.effective_plugin_skill_roots(), + restriction_product: Some(Product::Codex), + snapshots: manager.plugin_skill_snapshots_for_config(&config), + }) + .await; + let expected = if allow_curated || name == "openai-bundled" { + LoadedSkills { + skills: vec![SkillMetadata { + name: "sample:sample-search".to_string(), + description: "inspect sample data".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: AbsolutePathBuf::try_from(fs::canonicalize(skill_path)?)?, + scope: SkillScope::User, + plugin_id: Some(plugin_id), + remote_plugin_id: None, + }], + errors: Vec::new(), + } + } else { + LoadedSkills::default() + }; + assert_eq!( + loaded, expected, + "marketplace={name}, allow_curated={allow_curated}" + ); + } + Ok(()) +} diff --git a/codex-rs/core-plugins/src/marketplace_policy/curated_tests.rs b/codex-rs/core-plugins/src/marketplace_policy/curated_tests.rs new file mode 100644 index 0000000000..81e5fc0b32 --- /dev/null +++ b/codex-rs/core-plugins/src/marketplace_policy/curated_tests.rs @@ -0,0 +1,108 @@ +//! Checks the shared curated Git identity while preserving other managed marketplace exemptions. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn curated_catalogs_apply_the_existing_git_source_policy() { + let home = TempDir::new().unwrap(); + for (rule, allowed) in [ + ("", false), + ( + "source = 'git'\nurl = 'https://github.com/openai/plugins.git'", + true, + ), + ( + "source = 'git'\nurl = 'https://github.com/openai/plugins'", + true, + ), + ("source = 'git'\nurl = 'openai/plugins'", true), + ( + "source = 'git'\nurl = 'https://github.com/other/plugins.git'", + false, + ), + ( + "source = 'host_pattern'\nhost_pattern = '^github[.]com$'", + true, + ), + ( + "source = 'host_pattern'\nhost_pattern = '^git[.]example[.]com$'", + false, + ), + ( + "source = 'git'\nurl = 'https://github.com/openai/plugins.git'\nref = 'main'", + false, + ), + ( + "source = 'git'\nurl = 'openai/plugins'\n[marketplaces.allowed_sources.invalid]\nsource = 'host_pattern'\nhost_pattern = '('", + false, + ), + ] { + let rule = if rule.is_empty() { + String::new() + } else { + format!("[marketplaces.allowed_sources.curated]\n{rule}") + }; + let stack = config_layer_stack(&format!( + "[marketplaces]\nrestrict_to_allowed_sources = true\n{rule}" + )); + let policy = MarketplacePolicy::from_requirements(stack.requirements()); + for (name, manifest) in [ + ( + OPENAI_CURATED_MARKETPLACE_NAME, + curated_plugins_repo_path(home.path()).join(".agents/plugins/marketplace.json"), + ), + ( + OPENAI_API_CURATED_MARKETPLACE_NAME, + curated_plugins_api_marketplace_path(home.path()), + ), + ] { + let manifest = AbsolutePathBuf::try_from(manifest).unwrap(); + assert_eq!( + policy + .validate_install(&stack, home.path(), &manifest, name) + .is_ok(), + allowed + ); + } + } +} + +#[test] +fn a_local_allowlist_cannot_authorize_curated_but_other_managed_sources_remain_allowed() { + let home = TempDir::new().unwrap(); + let root = curated_plugins_repo_path(home.path()); + let stack = config_layer_stack(&format!( + "[marketplaces]\nrestrict_to_allowed_sources = true\n[marketplaces.allowed_sources.local]\nsource = 'local'\npath = {root:?}" + )); + let policy = MarketplacePolicy::from_requirements(stack.requirements()); + assert!( + policy + .validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None) + .is_err() + ); + // Windows test sandboxes may omit USERPROFILE, leaving no primary runtime cache root. + let primary_runtime = primary_runtime_marketplace_root() + .map(|root| (OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME, root)); + for (name, root) in [ + ( + OPENAI_BUNDLED_MARKETPLACE_NAME, + home.path().join(".tmp/bundled-marketplaces/openai-bundled"), + ), + ( + OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME, + home.path() + .join(".tmp/bundled-marketplaces/openai-bundled-alpha"), + ), + ] + .into_iter() + .chain(primary_runtime) + { + let manifest = + AbsolutePathBuf::try_from(root.join(".agents/plugins/marketplace.json")).unwrap(); + assert_eq!( + policy.validate_install(&stack, home.path(), &manifest, name), + Ok(()) + ); + } +} diff --git a/codex-rs/core-plugins/src/marketplace_policy_tests.rs b/codex-rs/core-plugins/src/marketplace_policy_tests.rs index 41fe45d6ed..9f56f8fccd 100644 --- a/codex-rs/core-plugins/src/marketplace_policy_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_policy_tests.rs @@ -11,6 +11,9 @@ use std::fs; use std::path::PathBuf; use tempfile::TempDir; +#[path = "marketplace_policy/curated_tests.rs"] +mod curated; + fn config_layer_stack(requirements_toml: &str) -> ConfigLayerStack { config_layer_stack_with_user_config(requirements_toml, /*user_config*/ None) } @@ -361,6 +364,9 @@ fn curated_marketplace_requires_its_expected_name() { r#" [marketplaces] restrict_to_allowed_sources = true +[marketplaces.allowed_sources.curated] +source = "git" +url = "https://github.com/openai/plugins.git" "#, ); let marketplace_path = AbsolutePathBuf::try_from( diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index 19234e6a5f..fe0e5de88b 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -27,7 +27,7 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; -const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +pub(crate) const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";