diff --git a/codex-rs/cli/src/mcp_cmd/cloud_config.rs b/codex-rs/cli/src/cloud_config.rs similarity index 98% rename from codex-rs/cli/src/mcp_cmd/cloud_config.rs rename to codex-rs/cli/src/cloud_config.rs index 2f7e7fba4f..3ac9047e95 100644 --- a/codex-rs/cli/src/mcp_cmd/cloud_config.rs +++ b/codex-rs/cli/src/cloud_config.rs @@ -12,7 +12,7 @@ use codex_core::config::load_config_toml_with_layer_stack; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; -pub(super) async fn load_mcp_config( +pub(crate) async fn load_config( config_overrides: &CliConfigOverrides, loader_overrides: LoaderOverrides, ) -> Result { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 75675efecc..4cfe030484 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -45,6 +45,7 @@ use supports_color::Stream; #[cfg(any(target_os = "macos", target_os = "windows"))] mod app_cmd; +mod cloud_config; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; mod doctor; @@ -1640,22 +1641,16 @@ async fn cli_main( root_remote_auth_token_env.as_deref(), "features list", )?; - let mut cli_kv_overrides = root_config_overrides - .parse_overrides() - .map_err(anyhow::Error::msg)?; - // Honor `--search` via the canonical web_search mode. if interactive.web_search { - cli_kv_overrides.push(( - "web_search".to_string(), - toml::Value::String("live".to_string()), - )); + root_config_overrides + .raw_overrides + .push("web_search=\"live\"".to_string()); } - let config = ConfigBuilder::default() - .cli_overrides(cli_kv_overrides) - .build() - .await?; + let config = + cloud_config::load_config(&root_config_overrides, LoaderOverrides::default()) + .await?; let mut rows = Vec::with_capacity(FEATURES.len()); let mut name_width = 0; let mut stage_width = 0; diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 57be665d97..c266bcd3ff 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -40,10 +40,9 @@ use codex_rmcp_client::perform_oauth_login; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::format_env_display; +use crate::cloud_config; use crate::plugin_cmd::load_cli_auth_mode; -mod cloud_config; - /// Subcommands: /// - `list` — list configured servers (with `--json`) /// - `get` — show a single server (with `--json`) @@ -227,13 +226,11 @@ impl McpCli { match subcommand { McpSubcommand::List(args) => { - let config = - cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?; + let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_list(&config, args).await?; } McpSubcommand::Get(args) => { - let config = - cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?; + let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_get(&config, args).await?; } McpSubcommand::Add(args) => { @@ -243,13 +240,11 @@ impl McpCli { run_remove(&config_overrides, args).await?; } McpSubcommand::Login(args) => { - let config = - cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?; + let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_login(&config, args).await?; } McpSubcommand::Logout(args) => { - let config = - cloud_config::load_mcp_config(&config_overrides, loader_overrides).await?; + let config = cloud_config::load_config(&config_overrides, loader_overrides).await?; run_logout(&config, args).await?; } } diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 596cfaa8f8..59a290611f 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -1,9 +1,23 @@ use std::path::Path; +use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_config::ConfigLoadOptions; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::config::load_config_toml_with_layer_stack; +use codex_utils_absolute_path::AbsolutePathBuf; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); @@ -117,3 +131,90 @@ async fn features_list_is_sorted_alphabetically_by_feature_name() -> Result<()> Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn features_list_honors_cloud_managed_feature_requirements() -> Result<()> { + let server = MockServer::start().await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let codex_home = TempDir::new()?; + let user_config = format!( + "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{chatgpt_base_url}\"\n\n[features]\nfast_mode = true\n" + ); + std::fs::write(codex_home.path().join("config.toml"), &user_config)?; + + let bootstrap_config = load_config_toml_with_layer_stack( + codex_home.path(), + Some(&AbsolutePathBuf::from_absolute_path(codex_home.path())?), + Vec::new(), + ConfigLoadOptions::default(), + ) + .await?; + if bootstrap_config.config_toml.cli_auth_credentials_store + != Some(AuthCredentialsStoreMode::File) + || bootstrap_config.config_toml.chatgpt_base_url.as_deref() + != Some(chatgpt_base_url.as_str()) + { + eprintln!( + "skipping cloud-managed feature subprocess: host-managed authentication or backend routing prevents isolated mock credentials" + ); + return Ok(()); + } + + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("workspace-123") + .chatgpt_account_id("workspace-123") + .chatgpt_user_id("user-123") + .plan_type("enterprise"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "workspace-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "requirements_toml": { + "enterprise_managed": [{ + "id": "managed-feature-requirements", + "name": "Managed feature requirements", + "contents": "[features]\nfast_mode = false\n", + }], + }, + }))) + .expect(1) + .mount(&server) + .await; + + let mut cmd = codex_command(codex_home.path())?; + let output = cmd + .current_dir(codex_home.path()) + .env("NO_PROXY", "127.0.0.1,localhost") + .env("no_proxy", "127.0.0.1,localhost") + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .args(["features", "list"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output)?; + let fast_mode = stdout + .lines() + .find(|line| line.starts_with("fast_mode ")) + .context("feature list should include fast_mode")?; + + assert_eq!( + fast_mode.split_whitespace().collect::>(), + ["fast_mode", "stable", "false"] + ); + assert_eq!( + std::fs::read_to_string(codex_home.path().join("config.toml"))?, + user_config + ); + server.verify().await; + + Ok(()) +}