diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c167627257..df9e6e33e5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -180,7 +180,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Exception: `remote_control` ignores `config.toml` feature entries so runtime enablement can own the app-server connection state. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). 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 d50683cf80..eca42d441a 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 @@ -325,6 +325,50 @@ async fn experimental_feature_enablement_set_allows_remote_control() -> Result<( Ok(()) } +#[tokio::test] +async fn experimental_feature_enablement_set_enables_remote_control_ignored_from_config_toml() +-> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nremote_control = true\n", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("remote_control")), + Some(&json!(false)) + ); + + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("remote_control".to_string(), true)]), + ) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("remote_control".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("remote_control")), + Some(&json!(true)) + ); + + Ok(()) +} + #[tokio::test] async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index c409b404d0..120dc7b729 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -8,6 +8,7 @@ use super::merge::merge_toml_values; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; use codex_app_server_protocol::ConfigLayerSource; +use codex_features::feature_for_key; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -301,7 +302,8 @@ impl ConfigLayerStack { ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { - merge_toml_values(&mut merged, &layer.config); + let config = effective_layer_config(layer); + merge_toml_values(&mut merged, &config); } merged } @@ -317,7 +319,8 @@ impl ConfigLayerStack { ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ false, ) { - let config = normalized_with_key_aliases(&layer.config, &[]); + let config = effective_layer_config(layer); + let config = normalized_with_key_aliases(&config, &[]); record_origins(&config, &layer.metadata(), &mut path, &mut origins); } @@ -354,6 +357,26 @@ impl ConfigLayerStack { } } +fn effective_layer_config(layer: &ConfigLayerEntry) -> TomlValue { + let mut config = layer.config.clone(); + if matches!(layer.name, ConfigLayerSource::SessionFlags) { + return config; + } + remove_config_toml_only_feature_overrides(&mut config); + config +} + +fn remove_config_toml_only_feature_overrides(config: &mut TomlValue) { + let Some(features) = config.get_mut("features").and_then(TomlValue::as_table_mut) else { + return; + }; + + features.retain(|key, _| match feature_for_key(key) { + Some(feature) => feature.can_override_from_config_toml(), + None => true, + }); +} + /// Ensures precedence ordering of config layers is correct. Returns the index /// of the user config layer, if any (at most one should exist). fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result> { diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs index 81e1fa63fc..d2e506fc54 100644 --- a/codex-rs/config/src/state_tests.rs +++ b/codex-rs/config/src/state_tests.rs @@ -1,4 +1,5 @@ use super::*; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[test] @@ -32,3 +33,64 @@ no_memories_if_mcp_or_web_search = true "legacy key should be canonicalized before origin recording" ); } + +#[test] +fn config_toml_layers_ignore_remote_control_feature_override() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::System { + file: AbsolutePathBuf::resolve_path_against_base("config.toml", std::env::temp_dir()), + }, + toml::from_str( + r#" +[features] +plugins = true +remote_control = true +"#, + ) + .expect("config TOML should parse"), + ); + let stack = ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("single layer stack should be valid"); + + let effective = stack.effective_config(); + let features = effective + .get("features") + .and_then(TomlValue::as_table) + .expect("features table should be present"); + assert_eq!(features.get("plugins"), Some(&TomlValue::Boolean(true))); + assert_eq!(features.get("remote_control"), None); +} + +#[test] +fn session_flags_preserve_remote_control_feature_override() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#" +[features] +remote_control = true +"#, + ) + .expect("config TOML should parse"), + ); + let stack = ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("single layer stack should be valid"); + + let effective = stack.effective_config(); + let features = effective + .get("features") + .and_then(TomlValue::as_table) + .expect("features table should be present"); + assert_eq!( + features.get("remote_control"), + Some(&TomlValue::Boolean(true)) + ); +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8ff8df4e83..7d79782c69 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4176,6 +4176,48 @@ async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { Ok(()) } +#[tokio::test] +async fn config_toml_remote_control_feature_is_ignored() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + features: Some(FeaturesToml::from(BTreeMap::from([( + "remote_control".to_string(), + true, + )]))), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(!config.features.enabled(Feature::RemoteControl)); + + Ok(()) +} + +#[tokio::test] +async fn cli_remote_control_feature_override_is_preserved() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + // `codex --enable remote_control` is folded into this session override + // before config loading. + .cli_overrides(vec![( + "features.remote_control".to_string(), + toml::Value::Boolean(true), + )]) + .build() + .await?; + + assert!(config.features.enabled(Feature::RemoteControl)); + + Ok(()) +} + #[tokio::test] async fn legacy_toggles_map_to_features() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9128,6 +9170,28 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { Ok(()) } +#[tokio::test] +async fn remote_control_feature_requirements_are_valid() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { + entries: BTreeMap::from([("remote_control".to_string(), true)]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(config.features.enabled(Feature::RemoteControl)); + + Ok(()) +} + #[tokio::test] async fn debug_config_lockfile_export_settings_load_from_nested_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index a575b0a45b..f2587a009f 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -258,6 +258,9 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature if let Some(features) = cfg.features.as_ref() { for (key, enabled) in features.entries() { if let Some(feature) = feature_for_key(&key) { + if !feature.can_override_from_config_toml() { + continue; + } explicit_settings.push((format!("features.{key}"), feature, enabled)); } } @@ -280,6 +283,9 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature if let Some(features) = profile.features.as_ref() { for (key, enabled) in features.entries() { if let Some(feature) = feature_for_key(&key) { + if !feature.can_override_from_config_toml() { + continue; + } explicit_settings.push(( format!("profiles.{profile_name}.features.{key}"), feature, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 955ae44c00..248545cc35 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -223,6 +223,9 @@ pub enum Feature { /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, /// Connect app-server to the ChatGPT remote control service. + /// + /// Runtime/requirements-only gate: this should not be overridden from + /// config.toml. RemoteControl, /// Removed compatibility flag retained as a no-op so old wrappers can /// still pass `--enable image_detail_original`. @@ -258,6 +261,12 @@ impl Feature { self.info().default_enabled } + /// Whether `[features]` entries in config.toml are allowed to override this + /// feature's effective state. + pub fn can_override_from_config_toml(self) -> bool { + !matches!(self, Feature::RemoteControl) + } + fn info(self) -> &'static FeatureSpec { FEATURES .iter() @@ -630,6 +639,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(apps_mcp_path_override, enabled); } else if spec.id == Feature::NetworkProxy { materialize_resolved_feature_enabled(network_proxy, enabled); + } else if !spec.id.can_override_from_config_toml() { + entries.remove(spec.key); } else { entries.insert(spec.key.to_string(), enabled); } diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a635ca0740..5afb9a4a0b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -567,6 +567,10 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( let entries = features_toml.entries(); assert_eq!(entries.get("include_apply_patch_tool"), None); for spec in crate::FEATURES { + if !spec.id.can_override_from_config_toml() { + assert_eq!(entries.get(spec.key), None, "{}", spec.key); + continue; + } assert_eq!( entries.get(spec.key), Some(&features.enabled(spec.id)),