diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b679cc34bb..37d1bb87a8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -219,6 +219,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; +use codex_chatgpt::workspace_settings; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::CodexThreadTurnContextOverrides; @@ -498,6 +499,7 @@ pub(crate) struct CodexMessageProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, command_exec_manager: CommandExecManager, + workspace_settings_cache: Arc, pending_fuzzy_searches: Arc>>>, fuzzy_search_sessions: Arc>>, background_tasks: TaskTracker, @@ -762,6 +764,9 @@ impl CodexMessageProcessor { thread_state_manager: ThreadStateManager::new(), thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing), command_exec_manager: CommandExecManager::default(), + workspace_settings_cache: Arc::new( + workspace_settings::WorkspaceSettingsCache::default(), + ), pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), background_tasks: TaskTracker::new(), @@ -784,6 +789,28 @@ impl CodexMessageProcessor { }) } + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -5608,6 +5635,10 @@ impl CodexMessageProcessor { return; } }; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; let data = FEATURES .iter() @@ -5642,7 +5673,9 @@ impl CodexMessageProcessor { display_name, description, announcement, - enabled: config.features.enabled(spec.id), + enabled: config.features.enabled(spec.id) + && (workspace_codex_plugins_enabled + || !matches!(spec.id, Feature::Apps | Feature::Plugins)), default_enabled: spec.default_enabled, } }) @@ -6430,6 +6463,22 @@ impl CodexMessageProcessor { return; } + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + self.outgoing + .send_response( + request_id, + AppsListResponse { + data: Vec::new(), + next_cursor: None, + }, + ) + .await; + return; + } + let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); let environment_manager = self.thread_manager.environment_manager(); @@ -6674,6 +6723,10 @@ impl CodexMessageProcessor { return; } }; + 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 @@ -6723,7 +6776,7 @@ impl CodexMessageProcessor { let effective_skill_roots = plugins_manager .effective_skill_roots_for_layer_stack( &config_layer_stack, - config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled, ) .await; let skills_input = codex_core::skills::SkillsLoadInput::new( diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index 405dd4523b..8f0f4dea9a 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -31,8 +31,24 @@ impl CodexMessageProcessor { .await; return; } - plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + self.outgoing + .send_response( + request_id, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }, + ) + .await; + return; + } + plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); let config_for_marketplace_listing = config.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); @@ -378,6 +394,26 @@ impl CodexMessageProcessor { } }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); + let config = match self.load_latest_config(config_cwd.clone()).await { + Ok(config) => config, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; + let auth = self.auth_manager.auth().await; + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + self.send_invalid_request_error( + request_id, + "Codex plugins are disabled for this workspace".to_string(), + ) + .await; + return; + } let plugins_manager = self.thread_manager.plugins_manager(); let request = PluginInstallRequest { @@ -395,7 +431,7 @@ impl CodexMessageProcessor { warn!( "failed to reload config after plugin install, using current config: {err:?}" ); - self.config.as_ref().clone() + config } }; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 78a915d178..335489929d 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -151,6 +151,68 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server_with_workspace_plugins_enabled( + connectors, tools, /*workspace_plugins_enabled*/ false, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Result<()> { let connectors = vec![AppInfo { @@ -1329,6 +1391,7 @@ struct AppsServerState { expected_account_id: String, response: Arc>, directory_delay: Duration, + workspace_plugins_enabled: bool, } #[derive(Clone)] @@ -1412,11 +1475,45 @@ async fn start_apps_server_with_delays( Ok((server_url, server_handle)) } +async fn start_apps_server_with_workspace_plugins_enabled( + connectors: Vec, + tools: Vec, + workspace_plugins_enabled: bool, +) -> Result<(String, JoinHandle<()>)> { + let (server_url, server_handle, _server_control) = + start_apps_server_with_delays_and_control_inner( + connectors, + tools, + Duration::ZERO, + Duration::ZERO, + workspace_plugins_enabled, + ) + .await?; + Ok((server_url, server_handle)) +} + async fn start_apps_server_with_delays_and_control( connectors: Vec, tools: Vec, directory_delay: Duration, tools_delay: Duration, +) -> Result<(String, JoinHandle<()>, AppsServerControl)> { + start_apps_server_with_delays_and_control_inner( + connectors, + tools, + directory_delay, + tools_delay, + /*workspace_plugins_enabled*/ true, + ) + .await +} + +async fn start_apps_server_with_delays_and_control_inner( + connectors: Vec, + tools: Vec, + directory_delay: Duration, + tools_delay: Duration, + workspace_plugins_enabled: bool, ) -> Result<(String, JoinHandle<()>, AppsServerControl)> { let response = Arc::new(StdMutex::new( json!({ "apps": connectors, "next_token": null }), @@ -1427,6 +1524,7 @@ async fn start_apps_server_with_delays_and_control( expected_account_id: "account-123".to_string(), response: response.clone(), directory_delay, + workspace_plugins_enabled, }; let state = Arc::new(state); let server_control = AppsServerControl { @@ -1452,6 +1550,10 @@ async fn start_apps_server_with_delays_and_control( "/connectors/directory/list_workspace", get(list_directory_connectors), ) + .route( + "/accounts/account-123/settings", + get(workspace_settings_response), + ) .with_state(state) .nest_service("/api/codex/apps", mcp_service); @@ -1462,6 +1564,30 @@ async fn start_apps_server_with_delays_and_control( Ok((format!("http://{addr}"), handle, server_control)) } +async fn workspace_settings_response( + State(state): State>, + headers: HeaderMap, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else { + Ok(Json(json!({ + "beta_settings": { + "plugins": state.workspace_plugins_enabled + } + }))) + } +} + async fn list_directory_connectors( State(state): State>, headers: HeaderMap, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 0c681e7fb9..30b4c0f325 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -1,8 +1,10 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ExperimentalFeature; @@ -14,6 +16,7 @@ use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; use codex_core::config_loader::LoaderOverrides; use codex_features::FEATURES; @@ -24,6 +27,12 @@ use serde_json::json; use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); @@ -89,6 +98,63 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu Ok(()) } +#[tokio::test] +async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_policy() +-> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let apps = actual + .data + .iter() + .find(|feature| feature.name == "apps") + .expect("apps feature should be present"); + let plugins = actual + .data + .iter() + .find(|feature| feature.name == "plugins") + .expect("plugins feature should be present"); + assert!(!apps.enabled); + assert!(!plugins.enabled); + assert!(apps.default_enabled); + assert!(plugins.default_enabled); + Ok(()) +} + #[tokio::test] async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() -> Result<()> { 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 c2ab2d1590..88403d8919 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -308,6 +308,72 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .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("Codex plugins are disabled for this workspace") + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { let codex_home = TempDir::new()?; @@ -907,6 +973,22 @@ connectors = true ) } +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_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 4ffab8f7d3..f885f2cb7a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -30,7 +30,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; @@ -45,6 +45,22 @@ plugins = true ) } +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 +"#, + ), + ) +} + #[tokio::test] async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> { let codex_home = TempDir::new()?; @@ -244,6 +260,158 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ Ok(()) } +#[tokio::test] +async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./demo-plugin" + } + } + ] +}"#, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "local-marketplace", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin"}"#, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":true}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + for _ in 0..2 { + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "local-marketplace"); + } + + wait_for_workspace_settings_request_count(&server, /*expected_count*/ 1).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins() -> Result<()> { @@ -1351,6 +1519,14 @@ async fn wait_for_featured_plugin_request_count( wait_for_remote_plugin_request_count(server, "/plugins/featured", expected_count).await } +async fn wait_for_workspace_settings_request_count( + server: &MockServer, + expected_count: usize, +) -> Result<()> { + wait_for_remote_plugin_request_count(server, "/accounts/account-123/settings", expected_count) + .await +} + async fn wait_for_remote_plugin_request_count( server: &MockServer, path_suffix: &str, 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 0d3bf4b491..e9c6e3bc00 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -2,8 +2,10 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; @@ -11,12 +13,19 @@ use codex_app_server_protocol::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; +use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const WATCHER_TIMEOUT: Duration = Duration::from_secs(20); fn write_skill(root: &TempDir, name: &str) -> Result<()> { @@ -27,6 +36,63 @@ 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_plugin_with_skill( + repo_root: &std::path::Path, + 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); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + + let skill_dir = plugin_root.join("skills").join(skill_name); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {skill_name}\ndescription: {skill_name} description\n---\n\n# Body\n"), + )?; + Ok(()) +} + #[tokio::test] async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; @@ -65,6 +131,71 @@ async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<( Ok(()) } +#[tokio::test] +async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = 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( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![repo_root.path().to_path_buf()], + force_reload: true, + per_cwd_extra_user_roots: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "home-skill"), + "non-plugin skills should remain available" + ); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "demo-plugin:plugin-skill"), + "plugin skills should be hidden when workspace Codex plugins are disabled" + ); + Ok(()) +} + #[tokio::test] async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 0f9bef956f..42aac41138 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -37,7 +37,11 @@ pub(crate) async fn chatgpt_get_request_with_timeout( // Make direct HTTP request to ChatGPT backend API with the token let client = create_client(); - let url = format!("{chatgpt_base_url}{path}"); + let url = format!( + "{}/{}", + chatgpt_base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ); let mut request = client .get(&url) diff --git a/codex-rs/chatgpt/src/lib.rs b/codex-rs/chatgpt/src/lib.rs index 057478db18..a245265d94 100644 --- a/codex-rs/chatgpt/src/lib.rs +++ b/codex-rs/chatgpt/src/lib.rs @@ -2,3 +2,4 @@ pub mod apply_command; mod chatgpt_client; pub mod connectors; pub mod get_task; +pub mod workspace_settings; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs new file mode 100644 index 0000000000..86e1a40871 --- /dev/null +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -0,0 +1,152 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use codex_core::config::Config; +use codex_login::CodexAuth; +use serde::Deserialize; + +use crate::chatgpt_client::chatgpt_get_request_with_timeout; + +const WORKSPACE_SETTINGS_TIMEOUT: Duration = Duration::from_secs(10); +const WORKSPACE_SETTINGS_CACHE_TTL: Duration = Duration::from_secs(15 * 60); +const CODEX_PLUGINS_BETA_SETTING: &str = "plugins"; + +#[derive(Debug, Deserialize)] +struct WorkspaceSettingsResponse { + #[serde(default)] + beta_settings: HashMap, +} + +#[derive(Debug, Default)] +pub struct WorkspaceSettingsCache { + entry: RwLock>, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WorkspaceSettingsCacheKey { + chatgpt_base_url: String, + account_id: String, +} + +#[derive(Clone, Debug)] +struct CachedWorkspaceSettings { + key: WorkspaceSettingsCacheKey, + expires_at: Instant, + codex_plugins_enabled: bool, +} + +impl WorkspaceSettingsCache { + fn get_codex_plugins_enabled(&self, key: &WorkspaceSettingsCacheKey) -> Option { + { + let entry = match self.entry.read() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if let Some(cached) = entry.as_ref() + && now < cached.expires_at + && cached.key == *key + { + return Some(cached.codex_plugins_enabled); + } + } + + let mut entry = match self.entry.write() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if entry + .as_ref() + .is_some_and(|cached| now >= cached.expires_at || cached.key != *key) + { + *entry = None; + } + None + } + + fn set_codex_plugins_enabled(&self, key: WorkspaceSettingsCacheKey, enabled: bool) { + let mut entry = match self.entry.write() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + *entry = Some(CachedWorkspaceSettings { + key, + expires_at: Instant::now() + WORKSPACE_SETTINGS_CACHE_TTL, + codex_plugins_enabled: enabled, + }); + } +} + +pub async fn codex_plugins_enabled_for_workspace( + config: &Config, + auth: Option<&CodexAuth>, + cache: Option<&WorkspaceSettingsCache>, +) -> anyhow::Result { + let Some(auth) = auth else { + return Ok(true); + }; + if !auth.is_chatgpt_auth() { + return Ok(true); + } + + let token_data = auth + .get_token_data() + .context("ChatGPT token data is not available")?; + if !token_data.id_token.is_workspace_account() { + return Ok(true); + } + + let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + return Ok(true); + }; + + let cache_key = WorkspaceSettingsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: account_id.to_string(), + }; + if let Some(cache) = cache + && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) + { + return Ok(enabled); + } + + let encoded_account_id = encode_path_segment(account_id); + let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( + config, + format!("/accounts/{encoded_account_id}/settings"), + Some(WORKSPACE_SETTINGS_TIMEOUT), + ) + .await?; + + let codex_plugins_enabled = settings + .beta_settings + .get(CODEX_PLUGINS_BETA_SETTING) + .copied() + .unwrap_or(true); + + if let Some(cache) = cache { + cache.set_codex_plugins_enabled(cache_key, codex_plugins_enabled); + } + + Ok(codex_plugins_enabled) +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +#[cfg(test)] +#[path = "workspace_settings_tests.rs"] +mod tests; diff --git a/codex-rs/chatgpt/src/workspace_settings_tests.rs b/codex-rs/chatgpt/src/workspace_settings_tests.rs new file mode 100644 index 0000000000..d84cc4c3a2 --- /dev/null +++ b/codex-rs/chatgpt/src/workspace_settings_tests.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn encode_path_segment_leaves_unreserved_ascii_unchanged() { + assert_eq!( + encode_path_segment("account-123_ABC.~"), + "account-123_ABC.~" + ); +} + +#[test] +fn encode_path_segment_escapes_path_separators_and_spaces() { + assert_eq!( + encode_path_segment("account/123 with space"), + "account%2F123%20with%20space" + ); +}