diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index fb542f045f..cbd9f55627 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,6 +1,7 @@ use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; use crate::is_openai_curated_marketplace_name; +use crate::manifest::PluginManifest; use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; @@ -10,6 +11,8 @@ use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; +use crate::startup_sync::curated_plugins_repo_path; +use crate::startup_sync::read_curated_plugins_sha; use crate::store::PluginStore; use crate::store::plugin_version_for_source; use codex_app_server_protocol::AuthMode; @@ -654,7 +657,7 @@ async fn load_plugin( let active_plugin_root = plugin_id .as_ref() .ok() - .and_then(|plugin_id| store.active_plugin_root(plugin_id)); + .and_then(|plugin_id| active_plugin_root(plugin_id, store)); let root = active_plugin_root .clone() .unwrap_or_else(|| match &plugin_id { @@ -665,6 +668,7 @@ async fn load_plugin( config_name, manifest_name: None, manifest_description: None, + is_first_party: false, root, enabled: plugin.enabled, skill_roots: Vec::new(), @@ -705,6 +709,7 @@ async fn load_plugin( return loaded_plugin; }; + loaded_plugin.is_first_party = is_openai_curated_plugin(&loaded_plugin_id, &manifest, store); let manifest_paths = &manifest.paths; match scope { PluginLoadScope::AllCapabilities { @@ -746,6 +751,59 @@ async fn load_plugin( loaded_plugin } +fn active_plugin_root(plugin_id: &PluginId, store: &PluginStore) -> Option { + if is_openai_curated_marketplace_name(&plugin_id.marketplace_name) + && let Some(curated_sha) = read_curated_plugins_sha(store.codex_home()) + { + let expected_version = curated_plugin_cache_version(&curated_sha); + let expected_root = store.plugin_root(plugin_id, &expected_version); + if expected_root.as_path().is_dir() { + return Some(expected_root); + } + } + store.active_plugin_root(plugin_id) +} + +fn is_openai_curated_plugin( + plugin_id: &PluginId, + manifest: &PluginManifest, + store: &PluginStore, +) -> bool { + if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name) + || manifest + .interface + .as_ref() + .and_then(|interface| interface.developer_name.as_deref()) + != Some("OpenAI") + { + return false; + } + + let Some(curated_sha) = read_curated_plugins_sha(store.codex_home()) else { + return false; + }; + let expected_version = curated_plugin_cache_version(&curated_sha); + if !store + .plugin_root(plugin_id, &expected_version) + .as_path() + .is_dir() + { + return false; + } + + let marketplace_path = + curated_plugins_repo_path(store.codex_home()).join(".agents/plugins/marketplace.json"); + let Ok(marketplace_path) = AbsolutePathBuf::try_from(marketplace_path) else { + return false; + }; + load_marketplace(&marketplace_path).is_ok_and(|marketplace| { + marketplace + .plugins + .iter() + .any(|plugin| plugin.name == plugin_id.plugin_name) + }) +} + fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) { config.enabled = policy.enabled; if let Some(approval_mode) = policy.default_tools_approval_mode { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 88b6cd3bb1..4f73e0a9a6 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -2,6 +2,7 @@ use super::LoadedPlugin; use super::PluginLoadOutcome; use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::is_openai_curated_marketplace_name; use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; @@ -1091,6 +1092,7 @@ impl PluginsManager { return Err(err.into()); } }; + self.validate_curated_marketplace_install_source(&request.marketplace_path, &resolved)?; let plugin_id = resolved.plugin_id.clone(); match self.install_resolved_plugin(resolved).await { Ok(outcome) => Ok(outcome), @@ -1122,6 +1124,7 @@ impl PluginsManager { return Err(err.into()); } }; + self.validate_curated_marketplace_install_source(&request.marketplace_path, &resolved)?; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. if let Err(err) = crate::remote_legacy::enable_remote_plugin( @@ -1208,6 +1211,33 @@ impl PluginsManager { } } + fn validate_curated_marketplace_install_source( + &self, + marketplace_path: &AbsolutePathBuf, + resolved: &ResolvedMarketplacePlugin, + ) -> Result<(), PluginInstallError> { + if resolved.plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME { + return Ok(()); + } + + let expected_path = curated_plugins_repo_path(self.codex_home.as_path()) + .join(".agents/plugins/marketplace.json"); + let expected_path = AbsolutePathBuf::try_from(expected_path) + .ok() + .and_then(|path| path.canonicalize().ok()); + let requested_path = marketplace_path.canonicalize().ok(); + if requested_path + .zip(expected_path) + .is_none_or(|(requested, expected)| requested != expected) + { + return Err(PluginStoreError::Invalid(format!( + "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' can only be installed from the synced curated marketplace" + )) + .into()); + } + Ok(()) + } + async fn install_resolved_plugin( &self, resolved: ResolvedMarketplacePlugin, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index c2917211d2..5ce9770e04 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -574,7 +574,10 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { &plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "sample", - "description": "Plugin that includes the sample MCP server and Skills" + "description": "Plugin that includes the sample MCP server and Skills", + "interface": { + "developerName": "OpenAI" + } }"#, ); write_file( @@ -622,6 +625,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { manifest_description: Some( "Plugin that includes the sample MCP server and Skills".to_string(), ), + is_first_party: false, root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills").abs()], @@ -1586,6 +1590,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions config_name: "sample@test".to_string(), manifest_name: None, manifest_description: None, + is_first_party: false, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), @@ -1754,6 +1759,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), manifest_description: None, + is_first_party: false, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), @@ -2077,6 +2083,38 @@ async fn install_openai_curated_plugin_uses_short_sha_cache_version() { ); } +#[tokio::test] +async fn install_plugin_rejects_spoofed_openai_curated_marketplace() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["slack"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let spoofed_root = tmp.path().join("spoofed-marketplace"); + write_openai_curated_marketplace(&spoofed_root, &["slack"]); + + let err = PluginsManager::new(tmp.path().to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: "slack".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + spoofed_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }) + .await + .unwrap_err(); + + assert!(err.is_invalid_request()); + assert!( + err.to_string() + .contains("can only be installed from the synced curated marketplace") + ); + assert!( + !tmp.path() + .join("plugins/cache/openai-curated/slack") + .exists() + ); +} + #[tokio::test] async fn install_plugin_uses_manifest_version_for_non_curated_plugins() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 19c3b0116f..a2bdab801a 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -26,6 +26,7 @@ pub struct PluginInstallResult { #[derive(Debug, Clone)] pub struct PluginStore { + codex_home: AbsolutePathBuf, root: AbsolutePathBuf, data_root: AbsolutePathBuf, } @@ -42,8 +43,18 @@ impl PluginStore { let data_root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR)) .map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?; + let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home) + .map_err(|err| PluginStoreError::io("failed to resolve Codex home", err))?; - Ok(Self { root, data_root }) + Ok(Self { + codex_home, + root, + data_root, + }) + } + + pub fn codex_home(&self) -> &AbsolutePathBuf { + &self.codex_home } pub fn root(&self) -> &AbsolutePathBuf { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bbdfc3f984..efd747b030 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -136,6 +136,7 @@ pub(super) async fn spawn_review_thread( turn_metadata_state, extension_data, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.snapshot.clone()), + first_party_plugin_roots: Arc::clone(&parent_turn_context.first_party_plugin_roots), turn_timing_state: Arc::new(TurnTimingState::default()), terminal_error: Arc::new(Mutex::new(None)), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 84c4a38887..01841c6492 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5048,6 +5048,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .plugins_manager .plugins_for_config(&per_turn_config.plugins_config_input()) .await; + let first_party_plugin_roots = plugin_outcome.effective_first_party_plugin_roots(); let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); @@ -5075,6 +5076,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.cwd().clone(), "turn_id".to_string(), skills_snapshot, + first_party_plugin_roots, ); let session = Session { @@ -7091,6 +7093,7 @@ where .plugins_manager .plugins_for_config(&per_turn_config.plugins_config_input()) .await; + let first_party_plugin_roots = plugin_outcome.effective_first_party_plugin_roots(); let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); @@ -7118,6 +7121,7 @@ where session_configuration.cwd().clone(), "turn_id".to_string(), skills_snapshot, + first_party_plugin_roots, )); let session = Arc::new(Session { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 91880bdefa..84c04fe5d3 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -6,6 +6,7 @@ use codex_core_skills::HostSkillsSnapshot; use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_plugin::FirstPartyPluginRoot; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::AdditionalPermissionProfile; @@ -138,6 +139,7 @@ pub struct TurnContext { pub(crate) turn_metadata_state: Arc, pub(crate) extension_data: Arc, pub(crate) turn_skills: TurnSkillsContext, + pub(crate) first_party_plugin_roots: Arc>, pub(crate) turn_timing_state: Arc, pub(crate) terminal_error: Arc>>, pub(crate) server_model_warning_emitted: AtomicBool, @@ -289,6 +291,7 @@ impl TurnContext { turn_metadata_state: self.turn_metadata_state.clone(), extension_data: Arc::clone(&self.extension_data), turn_skills: self.turn_skills.clone(), + first_party_plugin_roots: Arc::clone(&self.first_party_plugin_roots), turn_timing_state: Arc::clone(&self.turn_timing_state), terminal_error: Arc::clone(&self.terminal_error), server_model_warning_emitted: AtomicBool::new( @@ -494,6 +497,7 @@ impl Session { cwd: AbsolutePathBuf, sub_id: String, skills_snapshot: HostSkillsSnapshot, + first_party_plugin_roots: Vec, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -575,6 +579,7 @@ impl Session { turn_metadata_state, extension_data, turn_skills: TurnSkillsContext::new(skills_snapshot), + first_party_plugin_roots: Arc::new(first_party_plugin_roots), turn_timing_state: Arc::new(TurnTimingState::default()), terminal_error: Arc::new(Mutex::new(None)), server_model_warning_emitted: AtomicBool::new(false), @@ -727,6 +732,7 @@ impl Session { .plugins_manager .plugins_for_config(&per_turn_config.plugins_config_input()) .await; + let first_party_plugin_roots = plugin_outcome.effective_first_party_plugin_roots(); let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); let fs = primary_turn_environment @@ -764,6 +770,7 @@ impl Session { cwd, sub_id, skills_snapshot, + first_party_plugin_roots, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 7daa78bc06..822b568ffe 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -13,6 +13,7 @@ mod provider; use codex_config::HookEventsToml; use codex_utils_absolute_path::AbsolutePathBuf; pub use load_outcome::EffectiveSkillRoots; +pub use load_outcome::FirstPartyPluginRoot; pub use load_outcome::LoadedPlugin; pub use load_outcome::PluginLoadOutcome; pub use load_outcome::prompt_safe_plugin_description; diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 95b98d863a..c104af6473 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -11,6 +11,11 @@ use crate::PluginHookSource; use crate::app_connector_ids_from_declarations; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FirstPartyPluginRoot { + pub plugin_id: String, + pub plugin_root: AbsolutePathBuf, +} /// A plugin that was loaded from disk, including merged MCP server definitions. #[derive(Debug, Clone, PartialEq)] @@ -18,6 +23,7 @@ pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, pub manifest_description: Option, + pub is_first_party: bool, pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, @@ -141,6 +147,26 @@ impl PluginLoadOutcome { skill_roots } + pub fn effective_first_party_plugin_roots(&self) -> Vec { + let mut plugin_roots = Vec::new(); + let mut seen_paths = HashSet::new(); + for plugin in self + .plugins + .iter() + .filter(|plugin| plugin.is_active() && plugin.is_first_party) + { + if seen_paths.insert(plugin.root.clone()) { + plugin_roots.push(FirstPartyPluginRoot { + plugin_id: plugin.config_name.clone(), + plugin_root: plugin.root.clone(), + }); + } + } + + plugin_roots.sort_unstable_by(|a, b| a.plugin_root.cmp(&b.plugin_root)); + plugin_roots + } + pub fn effective_mcp_servers(&self) -> HashMap { let mut mcp_servers = HashMap::new(); for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { @@ -219,6 +245,7 @@ mod tests { config_name: config_name.to_string(), manifest_name: None, manifest_description: None, + is_first_party: false, root: test_path(config_name), enabled: true, skill_roots, @@ -249,4 +276,38 @@ mod tests { }] ); } + + #[test] + fn effective_first_party_plugin_roots_only_includes_active_openai_plugins() { + let shared_root = test_path("shared-plugin"); + let mut first_party = loaded_plugin("first@test", Vec::new()); + first_party.root = shared_root.clone(); + first_party.is_first_party = true; + let mut duplicate = loaded_plugin("duplicate@test", Vec::new()); + duplicate.root = shared_root.clone(); + duplicate.is_first_party = true; + let third_party = loaded_plugin("third@test", Vec::new()); + let mut disabled = loaded_plugin("disabled@test", Vec::new()); + disabled.is_first_party = true; + disabled.enabled = false; + let mut broken = loaded_plugin("broken@test", Vec::new()); + broken.is_first_party = true; + broken.error = Some("broken".to_string()); + + let outcome = PluginLoadOutcome::from_plugins(vec![ + first_party, + duplicate, + third_party, + disabled, + broken, + ]); + + assert_eq!( + outcome.effective_first_party_plugin_roots(), + vec![FirstPartyPluginRoot { + plugin_id: "first@test".to_string(), + plugin_root: shared_root, + }] + ); + } }