From d3b349c82e98e6d23b9d67ab20bb94ee80f0597f Mon Sep 17 00:00:00 2001 From: Xin Lin Date: Tue, 17 Mar 2026 23:51:24 -0700 Subject: [PATCH 1/2] feat: support product-scoped plugins. --- .../tests/suite/v2/plugin_install.rs | 50 +++++++ codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/plugins/manager.rs | 43 +++++- codex-rs/core/src/plugins/marketplace.rs | 6 +- .../core/src/plugins/marketplace_tests.rs | 44 +++++++ codex-rs/core/src/rollout/mod.rs | 12 +- codex-rs/core/src/rollout/tests.rs | 26 ++-- codex-rs/core/src/skills/manager.rs | 29 ++++- codex-rs/core/src/skills/mod.rs | 1 + codex-rs/core/src/skills/model.rs | 41 ++++++ codex-rs/core/src/thread_manager.rs | 18 ++- codex-rs/protocol/src/protocol.rs | 123 ++++++++++++++++++ codex-rs/tui/src/lib.rs | 4 +- codex-rs/tui/src/resume_picker.rs | 2 +- codex-rs/tui_app_server/src/resume_picker.rs | 2 +- 15 files changed, 371 insertions(+), 32 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index f286b5df1d9..a30107d3724 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -146,6 +146,56 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re Ok(()) } +#[tokio::test] +async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + }, + "policy": { + "products": ["CHATGPT"] + } + } + ] +}"#, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = + McpProcess::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path, + plugin_name: "sample-plugin".to_string(), + force_remote_sync: false, + }) + .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("not available for install")); + Ok(()) +} + #[tokio::test] async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()> { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a916f3311d3..8842caca86e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4770,7 +4770,7 @@ mod handlers { pub async fn list_skills( sess: &Session, sub_id: String, - cwds: Vec, + mut cwds: Vec, force_reload: bool, ) { let cwds = if cwds.is_empty() { diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d48cbc57c7c..cf68ed225b6 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -42,6 +42,7 @@ use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::MergeStrategy; +use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; @@ -461,16 +462,31 @@ pub struct PluginsManager { store: PluginStore, featured_plugin_ids_cache: RwLock>, cached_enabled_outcome: RwLock>, + restriction_product: Option, analytics_events_client: RwLock>, } impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { + Self::new_with_restriction_product(codex_home, Some(Product::Codex)) + } + + pub fn new_with_restriction_product( + codex_home: PathBuf, + restriction_product: Option, + ) -> Self { + // Product restrictions are enforced at marketplace admission time for a given CODEX_HOME: + // listing, install, and curated refresh all consult this restriction context before new + // plugins enter local config or cache. After admission, runtime plugin loading trusts the + // contents of that CODEX_HOME and does not re-filter configured plugins by product. + // + // This assumes a single CODEX_HOME is only used by one product. Self { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), cached_enabled_outcome: RwLock::new(None), + restriction_product, analytics_events_client: RwLock::new(None), } } @@ -483,6 +499,13 @@ impl PluginsManager { *stored_client = Some(analytics_events_client); } + fn restriction_product_matches(&self, products: &[Product]) -> bool { + products.is_empty() + || self + .restriction_product + .is_some_and(|product| product.matches_product_restriction(products)) + } + pub fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome { self.plugins_for_config_with_force_reload(config, /*force_reload*/ false) } @@ -600,7 +623,11 @@ impl PluginsManager { &self, request: PluginInstallRequest, ) -> Result { - let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; + let resolved = resolve_marketplace_plugin( + &request.marketplace_path, + &request.plugin_name, + self.restriction_product, + )?; self.install_resolved_plugin(resolved).await } @@ -610,7 +637,11 @@ impl PluginsManager { auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { - let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; + let resolved = resolve_marketplace_plugin( + &request.marketplace_path, + &request.plugin_name, + self.restriction_product, + )?; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. We rely on // `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra @@ -947,6 +978,9 @@ impl PluginsManager { if !seen_plugin_keys.insert(plugin_key.clone()) { return None; } + if !self.restriction_product_matches(&plugin.policy.products) { + return None; + } Some(ConfiguredMarketplacePlugin { // Enabled state is keyed by `@`, so duplicate @@ -1017,7 +1051,10 @@ impl PluginsManager { path, scope: SkillScope::User, })) - .skills; + .skills + .into_iter() + .filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product)) + .collect(); let apps = load_plugin_apps(source_path.as_path()); let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths); let mut mcp_server_names = Vec::new(); diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index aee612d713c..4c3564ee767 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -146,6 +146,7 @@ impl MarketplaceError { pub fn resolve_marketplace_plugin( marketplace_path: &AbsolutePathBuf, plugin_name: &str, + restriction_product: Option, ) -> Result { let marketplace = load_raw_marketplace_manifest(marketplace_path)?; let marketplace_name = marketplace.name; @@ -168,7 +169,10 @@ pub fn resolve_marketplace_plugin( .. } = plugin; let install_policy = policy.installation; - if install_policy == MarketplacePluginInstallPolicy::NotAvailable { + let product_allowed = policy.products.is_empty() + || restriction_product + .is_some_and(|product| product.matches_product_restriction(&policy.products)); + if install_policy == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed { return Err(MarketplaceError::PluginNotAvailable { plugin_name: name, marketplace_name, diff --git a/codex-rs/core/src/plugins/marketplace_tests.rs b/codex-rs/core/src/plugins/marketplace_tests.rs index bfdce5e186d..d15b628e346 100644 --- a/codex-rs/core/src/plugins/marketplace_tests.rs +++ b/codex-rs/core/src/plugins/marketplace_tests.rs @@ -30,6 +30,7 @@ fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() { let resolved = resolve_marketplace_plugin( &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), "local-plugin", + Some(Product::Codex), ) .unwrap(); @@ -59,6 +60,7 @@ fn resolve_marketplace_plugin_reports_missing_plugin() { let err = resolve_marketplace_plugin( &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), "missing", + Some(Product::Codex), ) .unwrap_err(); @@ -297,6 +299,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { let resolved = resolve_marketplace_plugin( &AbsolutePathBuf::try_from(repo_marketplace).unwrap(), "local-plugin", + Some(Product::Codex), ) .unwrap(); @@ -687,6 +690,7 @@ fn resolve_marketplace_plugin_rejects_non_relative_local_paths() { let err = resolve_marketplace_plugin( &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), "local-plugin", + Some(Product::Codex), ) .unwrap_err(); @@ -732,6 +736,7 @@ fn resolve_marketplace_plugin_uses_first_duplicate_entry() { let resolved = resolve_marketplace_plugin( &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), "local-plugin", + Some(Product::Codex), ) .unwrap(); @@ -740,3 +745,42 @@ fn resolve_marketplace_plugin_uses_first_duplicate_entry() { AbsolutePathBuf::try_from(repo_root.join("first")).unwrap() ); } + +#[test] +fn resolve_marketplace_plugin_rejects_disallowed_product() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "chatgpt-plugin", + "source": { + "source": "local", + "path": "./plugin" + }, + "policy": { + "products": ["CHATGPT"] + } + } + ] +}"#, + ) + .unwrap(); + + let err = resolve_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "chatgpt-plugin", + Some(Product::Atlas), + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "plugin `chatgpt-plugin` is not available for install in marketplace `codex-curated`" + ); +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 31ee26dcaa9..3b8ad9b4128 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -1,11 +1,19 @@ //! Rollout module: persistence and discovery of session rollout files. +use std::sync::LazyLock; + use codex_protocol::protocol::SessionSource; pub const SESSIONS_SUBDIR: &str = "sessions"; pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions"; -pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] = - &[SessionSource::Cli, SessionSource::VSCode]; +pub static INTERACTIVE_SESSION_SOURCES: LazyLock> = LazyLock::new(|| { + vec![ + SessionSource::Cli, + SessionSource::VSCode, + SessionSource::Custom("atlas".to_string()), + SessionSource::Custom("chatgpt".to_string()), + ] +}); pub(crate) mod error; pub mod list; diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 12af36b7831..c491e29757b 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -516,7 +516,7 @@ async fn test_list_conversations_latest_first() { 10, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -665,7 +665,7 @@ async fn test_pagination_cursor() { 2, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -733,7 +733,7 @@ async fn test_pagination_cursor() { 2, page1.next_cursor.as_ref(), ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -801,7 +801,7 @@ async fn test_pagination_cursor() { 2, page2.next_cursor.as_ref(), ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -854,7 +854,7 @@ async fn test_list_threads_scans_past_head_for_user_event() { 10, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -880,7 +880,7 @@ async fn test_get_thread_contents() { 1, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -970,7 +970,7 @@ async fn test_base_instructions_missing_in_meta_defaults_to_null() { 1, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -1013,7 +1013,7 @@ async fn test_base_instructions_present_in_meta_is_preserved() { 1, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -1064,7 +1064,7 @@ async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> { 1, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -1148,7 +1148,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { 1, None, ThreadSortKey::UpdatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -1188,7 +1188,7 @@ async fn test_stable_ordering_same_second_pagination() { 2, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -1256,7 +1256,7 @@ async fn test_stable_ordering_same_second_pagination() { 2, page1.next_cursor.as_ref(), ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) @@ -1325,7 +1325,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { 10, None, ThreadSortKey::CreatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), TEST_PROVIDER, ) diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 7aa3e6d7a79..4a0ec030d37 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::sync::RwLock; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; @@ -30,6 +31,7 @@ use crate::skills::system::uninstall_system_skills; pub struct SkillsManager { codex_home: PathBuf, plugins_manager: Arc, + restriction_product: Option, cache_by_cwd: RwLock>, cache_by_config: RwLock>, } @@ -39,10 +41,25 @@ impl SkillsManager { codex_home: PathBuf, plugins_manager: Arc, bundled_skills_enabled: bool, + ) -> Self { + Self::new_with_restriction_product( + codex_home, + plugins_manager, + bundled_skills_enabled, + Some(Product::Codex), + ) + } + + pub fn new_with_restriction_product( + codex_home: PathBuf, + plugins_manager: Arc, + bundled_skills_enabled: bool, + restriction_product: Option, ) -> Self { let manager = Self { codex_home, plugins_manager, + restriction_product, cache_by_cwd: RwLock::new(HashMap::new()), cache_by_config: RwLock::new(HashMap::new()), }; @@ -69,8 +86,10 @@ impl SkillsManager { return outcome; } - let outcome = - finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack); + let outcome = crate::skills::filter_skill_load_outcome_for_product( + finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack), + self.restriction_product, + ); let mut cache = self .cache_by_config .write() @@ -173,8 +192,10 @@ impl SkillsManager { scope: SkillScope::User, }), ); - let outcome = load_skills_from_roots(roots); - let outcome = finalize_skill_outcome(outcome, &config_layer_stack); + let outcome = crate::skills::filter_skill_load_outcome_for_product( + finalize_skill_outcome(load_skills_from_roots(roots), &config_layer_stack), + self.restriction_product, + ); let mut cache = self .cache_by_cwd .write() diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 8c311c5d345..4138ecbb869 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -20,4 +20,5 @@ pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; pub use model::SkillPolicy; +pub use model::filter_skill_load_outcome_for_product; pub use render::render_skills_section; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 0949300ec73..d47904b9c73 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -42,6 +42,21 @@ impl SkillMetadata { .and_then(|policy| policy.allow_implicit_invocation) .unwrap_or(true) } + + pub fn matches_product_restriction_for_product( + &self, + restriction_product: Option, + ) -> bool { + match &self.policy { + Some(policy) => { + policy.products.is_empty() + || restriction_product.is_some_and(|product| { + product.matches_product_restriction(&policy.products) + }) + } + None => true, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -115,3 +130,29 @@ impl SkillLoadOutcome { .map(|skill| (skill, self.is_skill_enabled(skill))) } } + +pub fn filter_skill_load_outcome_for_product( + mut outcome: SkillLoadOutcome, + restriction_product: Option, +) -> SkillLoadOutcome { + outcome + .skills + .retain(|skill| skill.matches_product_restriction_for_product(restriction_product)); + outcome.implicit_skills_by_scripts_dir = Arc::new( + outcome + .implicit_skills_by_scripts_dir + .iter() + .filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product)) + .map(|(path, skill)| (path.clone(), skill.clone())) + .collect(), + ); + outcome.implicit_skills_by_doc_path = Arc::new( + outcome + .implicit_skills_by_doc_path + .iter() + .filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product)) + .map(|(path, skill)| (path.clone(), skill.clone())) + .collect(), + ); + outcome +} diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 65f437de5a3..a63cf2cb947 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -169,18 +169,23 @@ impl ThreadManager { collaboration_modes_config: CollaborationModesConfig, ) -> Self { let codex_home = config.codex_home.clone(); + let restriction_product = session_source.restriction_product(); let openai_models_provider = config .model_providers .get(OPENAI_PROVIDER_ID) .cloned() .unwrap_or_else(|| ModelProviderInfo::create_openai_provider(/*base_url*/ None)); let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); - let plugins_manager = Arc::new(PluginsManager::new(codex_home.clone())); + let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product( + codex_home.clone(), + restriction_product, + )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( codex_home.clone(), Arc::clone(&plugins_manager), config.bundled_skills_enabled(), + restriction_product, )); let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager)); Self { @@ -236,12 +241,17 @@ impl ThreadManager { set_thread_manager_test_mode_for_tests(/*enabled*/ true); let auth_manager = AuthManager::from_auth_for_testing(auth); let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); - let plugins_manager = Arc::new(PluginsManager::new(codex_home.clone())); + let restriction_product = SessionSource::Exec.restriction_product(); + let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product( + codex_home.clone(), + restriction_product, + )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( + let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( codex_home.clone(), Arc::clone(&plugins_manager), /*bundled_skills_enabled*/ true, + restriction_product, )); let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager)); Self { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e5277c16bfb..4fb6d119f53 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2332,6 +2332,24 @@ impl SessionSource { _ => None, } } + pub fn restriction_product(&self) -> Option { + match self { + SessionSource::Custom(source) => Product::from_session_source_name(source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Unknown => Some(Product::Codex), + SessionSource::SubAgent(_) => None, + } + } + + pub fn matches_product_restriction(&self, products: &[Product]) -> bool { + products.is_empty() + || self + .restriction_product() + .is_some_and(|product| product.matches_product_restriction(products)) + } } impl fmt::Display for SubAgentSource { @@ -2923,6 +2941,21 @@ pub enum Product { #[serde(alias = "ATLAS")] Atlas, } +impl Product { + pub fn from_session_source_name(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "chatgpt" => Some(Self::Chatgpt), + "codex" => Some(Self::Codex), + "atlas" => Some(Self::Atlas), + _ => None, + } + } + + pub fn matches_product_restriction(&self, products: &[Product]) -> bool { + products.is_empty() || products.contains(self) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -3423,6 +3456,96 @@ mod tests { .any(|root| root.is_path_writable(path)) } + #[test] + fn session_source_from_startup_arg_maps_known_values() { + assert_eq!( + SessionSource::from_startup_arg("vscode").unwrap(), + SessionSource::VSCode + ); + assert_eq!( + SessionSource::from_startup_arg("app-server").unwrap(), + SessionSource::Mcp + ); + } + + #[test] + fn session_source_from_startup_arg_preserves_custom_values() { + assert_eq!( + SessionSource::from_startup_arg("atlas").unwrap(), + SessionSource::Custom("atlas".to_string()) + ); + } + + #[test] + fn session_source_restriction_product_defaults_non_subagent_sources_to_codex() { + assert_eq!( + SessionSource::Cli.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::VSCode.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Exec.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Mcp.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Unknown.restriction_product(), + Some(Product::Codex) + ); + } + + #[test] + fn session_source_restriction_product_does_not_guess_subagent_products() { + assert_eq!( + SessionSource::SubAgent(SubAgentSource::Review).restriction_product(), + None + ); + } + + #[test] + fn session_source_restriction_product_maps_custom_sources_to_products() { + assert_eq!( + SessionSource::Custom("chatgpt".to_string()).restriction_product(), + Some(Product::Chatgpt) + ); + assert_eq!( + SessionSource::Custom("ATLAS".to_string()).restriction_product(), + Some(Product::Atlas) + ); + assert_eq!( + SessionSource::Custom("codex".to_string()).restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Custom("atlas-dev".to_string()).restriction_product(), + None + ); + } + + #[test] + fn session_source_matches_product_restriction() { + assert!( + SessionSource::Custom("chatgpt".to_string()) + .matches_product_restriction(&[Product::Chatgpt]) + ); + assert!( + !SessionSource::Custom("chatgpt".to_string()) + .matches_product_restriction(&[Product::Codex]) + ); + assert!(SessionSource::VSCode.matches_product_restriction(&[Product::Codex])); + assert!( + !SessionSource::Custom("atlas-dev".to_string()) + .matches_product_restriction(&[Product::Atlas]) + ); + assert!(SessionSource::Custom("atlas-dev".to_string()).matches_product_restriction(&[])); + } + fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec { let mut paths = vec![cwd.to_path_buf()]; paths.extend( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7fefaaccc44..5ecb87dd276 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -735,7 +735,7 @@ async fn run_ratatui_app( /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::UpdatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), &config.model_provider_id, /*search_term*/ None, @@ -835,7 +835,7 @@ async fn run_ratatui_app( /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::UpdatedAt, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), &config.model_provider_id, filter_cwd, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index f2d8db0fcbe..1a74fcd83a4 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -164,7 +164,7 @@ async fn run_session_picker( PAGE_SIZE, request.cursor.as_ref(), request.sort_key, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), request.default_provider.as_str(), /*search_term*/ None, diff --git a/codex-rs/tui_app_server/src/resume_picker.rs b/codex-rs/tui_app_server/src/resume_picker.rs index b1dab17f5f5..debb887aaf1 100644 --- a/codex-rs/tui_app_server/src/resume_picker.rs +++ b/codex-rs/tui_app_server/src/resume_picker.rs @@ -322,7 +322,7 @@ fn spawn_rollout_page_loader( PAGE_SIZE, cursor, request.sort_key, - INTERACTIVE_SESSION_SOURCES, + INTERACTIVE_SESSION_SOURCES.as_slice(), default_provider.as_ref().map(std::slice::from_ref), default_provider.as_deref().unwrap_or_default(), /*search_term*/ None, From 06e9a1ad83a7634f4ef96341095499d474390f9b Mon Sep 17 00:00:00 2001 From: Xin Lin Date: Wed, 18 Mar 2026 21:06:54 -0700 Subject: [PATCH 2/2] Address comments. --- .../schema/json/ServerNotification.json | 13 +++++++++ .../codex_app_server_protocol.schemas.json | 13 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 13 +++++++++ .../schema/json/v2/ThreadForkResponse.json | 13 +++++++++ .../schema/json/v2/ThreadListResponse.json | 13 +++++++++ .../json/v2/ThreadMetadataUpdateResponse.json | 13 +++++++++ .../schema/json/v2/ThreadReadResponse.json | 13 +++++++++ .../schema/json/v2/ThreadResumeResponse.json | 13 +++++++++ .../json/v2/ThreadRollbackResponse.json | 13 +++++++++ .../schema/json/v2/ThreadStartResponse.json | 13 +++++++++ .../json/v2/ThreadStartedNotification.json | 13 +++++++++ .../json/v2/ThreadUnarchiveResponse.json | 13 +++++++++ .../schema/typescript/SessionSource.ts | 2 +- .../schema/typescript/v2/SessionSource.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 3 +++ .../app-server/src/codex_message_processor.rs | 13 ++++++++- codex-rs/app-server/src/lib.rs | 4 ++- codex-rs/app-server/src/main.rs | 12 +++++++++ .../app-server/tests/common/mcp_process.rs | 15 ++++++++++- .../app-server/tests/suite/v2/plugin_read.rs | 27 +++++++++++++++++++ codex-rs/cli/src/main.rs | 1 + codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/plugins/manager.rs | 25 ++++++++++++++--- codex-rs/core/src/skills/manager.rs | 16 ++++++++--- codex-rs/protocol/src/protocol.rs | 25 ++++++++++++++++- 25 files changed, 289 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8bb9f254832..93557e9c1e6 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1880,6 +1880,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index df25bf911d4..ce67220ea94 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10991,6 +10991,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a932ee0392e..0e1ba82ca65 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8751,6 +8751,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 04765cf484a..2667415008e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -826,6 +826,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 9366304000c..c4410dada36 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 57dea225e25..9e4fa363ae2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 295938ba855..762c585f6a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 774c3cade36..55602103710 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -826,6 +826,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 518f560a278..b24218778c9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index a6746e1eb18..7f3f848e67a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -826,6 +826,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index a2307578d2d..70b995b24d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 64c00271fb8..ae9ebb57dfe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts b/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts index e5e746e3844..a80b013b22c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SubAgentSource } from "./SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "subagent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "custom": string } | { "subagent": SubAgentSource } | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts index b35b421fcd7..852e6ded971 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SubAgentSource } from "../SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | { "subAgent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | { "custom": string } | { "subAgent": SubAgentSource } | "unknown"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 25a035cac49..ceec0230e13 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1467,6 +1467,7 @@ pub enum SessionSource { VsCode, Exec, AppServer, + Custom(String), SubAgent(CoreSubAgentSource), #[serde(other)] Unknown, @@ -1479,6 +1480,7 @@ impl From for SessionSource { CoreSessionSource::VSCode => SessionSource::VsCode, CoreSessionSource::Exec => SessionSource::Exec, CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::Custom(source) => SessionSource::Custom(source), CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), CoreSessionSource::Unknown => SessionSource::Unknown, } @@ -1492,6 +1494,7 @@ impl From for CoreSessionSource { SessionSource::VsCode => CoreSessionSource::VSCode, SessionSource::Exec => CoreSessionSource::Exec, SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Custom(source) => CoreSessionSource::Custom(source), SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), SessionSource::Unknown => CoreSessionSource::Unknown, } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index deee837fe7b..f37b73975c6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5573,6 +5573,17 @@ impl CodexMessageProcessor { }; let app_summaries = plugin_app_helpers::load_plugin_app_summaries(&config, &outcome.plugin.apps).await; + let visible_skills = outcome + .plugin + .skills + .iter() + .filter(|skill| { + skill.matches_product_restriction_for_product( + self.thread_manager.session_source().restriction_product(), + ) + }) + .cloned() + .collect::>(); let plugin = PluginDetail { marketplace_name: outcome.marketplace_name, marketplace_path: outcome.marketplace_path, @@ -5587,7 +5598,7 @@ impl CodexMessageProcessor { interface: outcome.plugin.interface.map(plugin_interface_to_info), }, description: outcome.plugin.description, - skills: plugin_skills_to_info(&outcome.plugin.skills), + skills: plugin_skills_to_info(&visible_skills), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, }; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 85804098bdb..8b4afc23d02 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -336,6 +336,7 @@ pub async fn run_main( loader_overrides, default_analytics_enabled, AppServerTransport::Stdio, + SessionSource::VSCode, ) .await } @@ -346,6 +347,7 @@ pub async fn run_main_with_transport( loader_overrides: LoaderOverrides, default_analytics_enabled: bool, transport: AppServerTransport, + session_source: SessionSource, ) -> IoResult<()> { let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -621,7 +623,7 @@ pub async fn run_main_with_transport( feedback: feedback.clone(), log_db, config_warnings, - session_source: SessionSource::VSCode, + session_source, enable_codex_api_key_env: false, }); let mut thread_created_rx = processor.thread_created_receiver(); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 11380154fb5..60fa0a777be 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -4,6 +4,7 @@ use codex_app_server::run_main_with_transport; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_core::config_loader::LoaderOverrides; +use codex_protocol::protocol::SessionSource; use codex_utils_cli::CliConfigOverrides; use std::path::PathBuf; @@ -21,6 +22,15 @@ struct AppServerArgs { default_value = AppServerTransport::DEFAULT_LISTEN_URL )] listen: AppServerTransport, + + /// Session source used to derive product restrictions and metadata. + #[arg( + long = "session-source", + value_name = "SOURCE", + default_value = "vscode", + value_parser = SessionSource::from_startup_arg + )] + session_source: SessionSource, } fn main() -> anyhow::Result<()> { @@ -32,6 +42,7 @@ fn main() -> anyhow::Result<()> { ..Default::default() }; let transport = args.listen; + let session_source = args.session_source; run_main_with_transport( arg0_paths, @@ -39,6 +50,7 @@ fn main() -> anyhow::Result<()> { loader_overrides, /*default_analytics_enabled*/ false, transport, + session_source, ) .await?; Ok(()) diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 430a400a2c2..9c0bbd211ab 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -95,7 +95,11 @@ pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { - Self::new_with_env(codex_home, &[]).await + Self::new_with_env_and_args(codex_home, &[], &[]).await + } + + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, &[], args).await } /// Creates a new MCP process, allowing tests to override or remove @@ -106,6 +110,14 @@ impl McpProcess { pub async fn new_with_env( codex_home: &Path, env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, env_overrides, &[]).await + } + + async fn new_with_env_and_args( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + args: &[&str], ) -> anyhow::Result { let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find binary for codex-app-server")?; @@ -118,6 +130,7 @@ impl McpProcess { cmd.env("CODEX_HOME", codex_home); cmd.env("RUST_LOG", "info"); cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); + cmd.args(args); for (k, v) in env_overrides { match v { 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 98d0fa8f37c..d4dadea7b14 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -25,6 +25,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?; + std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?; std::fs::write( repo_root.path().join(".agents/plugins/marketplace.json"), r#"{ @@ -79,6 +80,32 @@ description: Summarize email threads --- # Thread Summarizer +"#, + )?; + std::fs::write( + plugin_root.join("skills/chatgpt-only/SKILL.md"), + r#"--- +name: chatgpt-only +description: Visible only for ChatGPT +--- + +# ChatGPT Only +"#, + )?; + std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer/agents"))?; + std::fs::write( + plugin_root.join("skills/thread-summarizer/agents/openai.yaml"), + r#"policy: + products: + - CODEX +"#, + )?; + std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only/agents"))?; + std::fs::write( + plugin_root.join("skills/chatgpt-only/agents/openai.yaml"), + r#"policy: + products: + - CHATGPT "#, )?; std::fs::write( diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 05b568b7b7f..93863982846 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -649,6 +649,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_core::config_loader::LoaderOverrides::default(), app_server_cli.analytics_default_enabled, transport, + codex_protocol::protocol::SessionSource::VSCode, ) .await?; } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8842caca86e..a916f3311d3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4770,7 +4770,7 @@ mod handlers { pub async fn list_skills( sess: &Session, sub_id: String, - mut cwds: Vec, + cwds: Vec, force_reload: bool, ) { let cwds = if cwds.is_empty() { diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index cf68ed225b6..f28bcc2c48f 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -478,7 +478,8 @@ impl PluginsManager { // Product restrictions are enforced at marketplace admission time for a given CODEX_HOME: // listing, install, and curated refresh all consult this restriction context before new // plugins enter local config or cache. After admission, runtime plugin loading trusts the - // contents of that CODEX_HOME and does not re-filter configured plugins by product. + // contents of that CODEX_HOME and does not re-filter configured plugins by product, so + // already-admitted plugins may continue exposing MCP servers/tools from shared local state. // // This assumes a single CODEX_HOME is only used by one product. Self { @@ -806,6 +807,7 @@ impl PluginsManager { AbsolutePathBuf, Option, Option, + bool, )>::new(); let mut local_plugin_names = HashSet::new(); for plugin in curated_marketplace.plugins { @@ -828,12 +830,14 @@ impl PluginsManager { .get(&plugin_key) .map(|plugin| plugin.enabled); let installed_version = self.store.active_plugin_version(&plugin_id); + let product_allowed = self.restriction_product_matches(&plugin.policy.products); local_plugins.push(( plugin_name, plugin_id, source_path, current_enabled, installed_version, + product_allowed, )); } @@ -872,11 +876,20 @@ impl PluginsManager { let remote_plugin_count = remote_installed_plugin_names.len(); let local_plugin_count = local_plugins.len(); - for (plugin_name, plugin_id, source_path, current_enabled, installed_version) in - local_plugins + for ( + plugin_name, + plugin_id, + source_path, + current_enabled, + installed_version, + product_allowed, + ) in local_plugins { let plugin_key = plugin_id.as_key(); let is_installed = installed_version.is_some(); + if !product_allowed { + continue; + } if remote_installed_plugin_names.contains(&plugin_name) { if !is_installed { installs.push(( @@ -1028,6 +1041,12 @@ impl PluginsManager { marketplace_name, }); }; + if !self.restriction_product_matches(&plugin.policy.products) { + return Err(MarketplaceError::PluginNotFound { + plugin_name: request.plugin_name.clone(), + marketplace_name, + }); + } let plugin_id = PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err( |err| match err { diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 4a0ec030d37..982780f821c 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -192,10 +192,7 @@ impl SkillsManager { scope: SkillScope::User, }), ); - let outcome = crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), &config_layer_stack), - self.restriction_product, - ); + let outcome = self.build_skill_outcome(roots, &config_layer_stack); let mut cache = self .cache_by_cwd .write() @@ -204,6 +201,17 @@ impl SkillsManager { outcome } + fn build_skill_outcome( + &self, + roots: Vec, + config_layer_stack: &crate::config_loader::ConfigLayerStack, + ) -> SkillLoadOutcome { + crate::skills::filter_skill_load_outcome_for_product( + finalize_skill_outcome(load_skills_from_roots(roots), config_layer_stack), + self.restriction_product, + ) + } + pub fn clear_cache(&self) { let cleared_cwd = { let mut cache = self diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4fb6d119f53..beccedb781b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2272,6 +2272,7 @@ pub enum SessionSource { VSCode, Exec, Mcp, + Custom(String), SubAgent(SubAgentSource), #[serde(other)] Unknown, @@ -2302,6 +2303,7 @@ impl fmt::Display for SessionSource { SessionSource::VSCode => f.write_str("vscode"), SessionSource::Exec => f.write_str("exec"), SessionSource::Mcp => f.write_str("mcp"), + SessionSource::Custom(source) => f.write_str(source), SessionSource::SubAgent(sub_source) => write!(f, "subagent_{sub_source}"), SessionSource::Unknown => f.write_str("unknown"), } @@ -2309,6 +2311,23 @@ impl fmt::Display for SessionSource { } impl SessionSource { + pub fn from_startup_arg(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("session source must not be empty"); + } + + let normalized = trimmed.to_ascii_lowercase(); + Ok(match normalized.as_str() { + "cli" => SessionSource::Cli, + "vscode" => SessionSource::VSCode, + "exec" => SessionSource::Exec, + "mcp" | "appserver" | "app-server" | "app_server" => SessionSource::Mcp, + "unknown" => SessionSource::Unknown, + _ => SessionSource::Custom(normalized), + }) + } + pub fn get_nickname(&self) -> Option { match self { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => { @@ -3469,11 +3488,15 @@ mod tests { } #[test] - fn session_source_from_startup_arg_preserves_custom_values() { + fn session_source_from_startup_arg_normalizes_custom_values() { assert_eq!( SessionSource::from_startup_arg("atlas").unwrap(), SessionSource::Custom("atlas".to_string()) ); + assert_eq!( + SessionSource::from_startup_arg(" Atlas ").unwrap(), + SessionSource::Custom("atlas".to_string()) + ); } #[test]