diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 91fabf76166..8e46f275d92 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -390,10 +390,6 @@ impl CatalogRequestProcessor { }; let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - let auth = self.auth_manager.auth().await; - let workspace_codex_plugins_enabled = self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await; let skills_manager = self.thread_manager.skills_manager(); let plugins_manager = self.thread_manager.plugins_manager(); let fs = self @@ -425,17 +421,10 @@ impl CatalogRequestProcessor { ); } }; - let effective_skill_roots = if workspace_codex_plugins_enabled { - let plugins_input = config.plugins_config_input(); - plugins_manager - .effective_skill_roots_for_layer_stack( - &config_layer_stack, - &plugins_input, - ) - .await - } else { - Vec::new() - }; + let plugins_input = config.plugins_config_input(); + let effective_skill_roots = plugins_manager + .effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input) + .await; let skills_input = codex_core::skills::SkillsLoadInput::new( cwd_abs.clone(), effective_skill_roots, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515adf..dc0d670d0eb 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -40,22 +40,6 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> { Ok(()) } -fn write_plugins_enabled_config_with_base_url( - codex_home: &std::path::Path, - base_url: &str, -) -> std::io::Result<()> { - std::fs::write( - codex_home.join("config.toml"), - format!( - r#"chatgpt_base_url = "{base_url}" - -[features] -plugins = true -"#, - ), - ) -} - fn write_remote_plugins_enabled_config_with_base_url( codex_home: &std::path::Path, base_url: &str, @@ -73,32 +57,17 @@ remote_plugin = true ) } -fn write_plugin_with_skill( - repo_root: &std::path::Path, +fn write_cached_local_plugin_with_skill( + codex_home: &std::path::Path, + marketplace_name: &str, plugin_name: &str, skill_name: &str, ) -> Result<()> { - std::fs::create_dir_all(repo_root.join(".git"))?; - std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; - std::fs::write( - repo_root.join(".agents/plugins/marketplace.json"), - format!( - r#"{{ - "name": "local-marketplace", - "plugins": [ - {{ - "name": "{plugin_name}", - "source": {{ - "source": "local", - "path": "./{plugin_name}" - }} - }} - ] -}}"# - ), - )?; - - let plugin_root = repo_root.join(plugin_name); + let plugin_root = codex_home + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), @@ -316,15 +285,30 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result } #[tokio::test] -async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> { +async fn skills_list_keeps_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; - let repo_root = TempDir::new()?; + let cwd = TempDir::new()?; let server = MockServer::start().await; write_skill(&codex_home, "home-skill")?; - write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?; - write_plugins_enabled_config_with_base_url( + write_cached_local_plugin_with_skill( codex_home.path(), - &format!("{}/backend-api/", server.uri()), + "debug", + "demo-plugin", + "plugin-skill", + )?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true + +[plugins."demo-plugin@debug"] +enabled = true +"#, + server.uri() + ), )?; write_chatgpt_auth( codex_home.path(), @@ -351,7 +335,7 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable let request_id = mcp .send_skills_list_request(SkillsListParams { - cwds: vec![repo_root.path().to_path_buf()], + cwds: vec![cwd.path().to_path_buf()], force_reload: true, }) .await?; @@ -374,8 +358,8 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable data[0] .skills .iter() - .all(|skill| skill.name != "demo-plugin:plugin-skill"), - "plugin skills should be hidden when workspace Codex plugins are disabled" + .any(|skill| skill.name == "demo-plugin:plugin-skill"), + "plugin-backed skills should remain available when workspace Codex plugins are disabled" ); Ok(()) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7a74e0f9563..a0b0120a7d2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -690,6 +690,14 @@ impl App { } }; let bootstrap = app_server.bootstrap(&config).await?; + if !bootstrap.plugins_enabled + && let Err(err) = config.features.disable(Feature::Plugins) + { + tracing::warn!( + error = %err, + "failed to apply app-server plugin visibility to the TUI config" + ); + } let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let exit_info = handle_model_migration_prompt_if_needed( diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 56ad0ccdea6..baa48764626 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -20,6 +20,8 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportParams; @@ -104,6 +106,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_features::Feature; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; @@ -145,6 +148,7 @@ pub(crate) struct AppServerBootstrap { pub(crate) default_model: String, pub(crate) feedback_audience: FeedbackAudience, pub(crate) has_chatgpt_account: bool, + pub(crate) plugins_enabled: bool, pub(crate) available_models: Vec, } @@ -218,6 +222,7 @@ impl AppServerSession { .into_iter() .map(model_preset_from_api_model) .collect::>(); + let plugins_enabled = self.plugins_enabled_for_cli(config).await; let default_model = config .model .clone() @@ -278,10 +283,32 @@ impl AppServerSession { default_model, feedback_audience, has_chatgpt_account, + plugins_enabled, available_models, }) } + async fn plugins_enabled_for_cli(&mut self, config: &Config) -> bool { + let request_id = self.next_request_id(); + match self + .client + .request_typed(ClientRequest::ExperimentalFeatureList { + request_id, + params: ExperimentalFeatureListParams::default(), + }) + .await + { + Ok(response) => plugins_enabled_from_feature_list(config, &response), + Err(err) => { + tracing::warn!( + error = %err, + "experimentalFeature/list failed during TUI bootstrap; keeping local plugin feature state" + ); + config.features.enabled(Feature::Plugins) + } + } + } + /// Fetches the current account info without refreshing the auth token. /// /// Used by both `bootstrap` (to populate the initial UI) and `get_login_status` @@ -1022,6 +1049,20 @@ fn thread_realtime_start_params( .wrap_err("mapping TUI realtime start params to app-server params") } +fn plugins_enabled_from_feature_list( + config: &Config, + response: &ExperimentalFeatureListResponse, +) -> bool { + response + .data + .iter() + .find(|feature| feature.name == Feature::Plugins.key()) + .map_or_else( + || config.features.enabled(Feature::Plugins), + |feature| feature.enabled, + ) +} + pub(crate) fn status_account_display_from_auth_mode( auth_mode: Option, plan_type: Option, @@ -1608,6 +1649,45 @@ mod tests { .expect("config should build") } + #[tokio::test] + async fn plugins_enabled_from_feature_list_uses_app_server_effective_state() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + assert!(config.features.enabled(Feature::Plugins)); + + let response = ExperimentalFeatureListResponse { + data: vec![codex_app_server_protocol::ExperimentalFeature { + name: Feature::Plugins.key().to_string(), + stage: codex_app_server_protocol::ExperimentalFeatureStage::Stable, + display_name: None, + description: None, + announcement: None, + enabled: false, + default_enabled: true, + }], + next_cursor: None, + }; + + assert!(!plugins_enabled_from_feature_list(&config, &response)); + } + + #[tokio::test] + async fn plugins_enabled_from_feature_list_falls_back_to_local_feature_state() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config + .features + .disable(Feature::Plugins) + .expect("plugins feature should be mutable in tests"); + + let response = ExperimentalFeatureListResponse { + data: Vec::new(), + next_cursor: None, + }; + + assert!(!plugins_enabled_from_feature_list(&config, &response)); + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir");