diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 43bbed6041..809d5fa28f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -856,6 +856,79 @@ model_reasoning_effort = "high" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_respects_managed_project_root_markers() -> Result<()> { + let codex_home = TempDir::new()?; + write_config(&codex_home, "model_context_window = 16384\n")?; + let workspace = TempDir::new()?; + let ancestor_config = workspace.path().join(".codex"); + let child = workspace.path().join("child"); + let child_config = child.join(".codex"); + for dir in [ + workspace.path().join(".git"), + ancestor_config.clone(), + child_config.clone(), + ] { + std::fs::create_dir_all(dir)?; + } + std::fs::write( + ancestor_config.join("config.toml"), + "model_context_window = 32768\n", + )?; + std::fs::write( + child_config.join("config.toml"), + "model_reasoning_effort = \"high\"\n", + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + let managed_path = codex_home.path().join("managed_config.toml"); + std::fs::write(&managed_path, "project_root_markers = []\n")?; + let managed_path = managed_path.to_string_lossy().into_owned(); + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .with_env_overrides(&[("CODEX_APP_SERVER_MANAGED_CONFIG_PATH", Some(&managed_path))]) + .build_initialized_with_timeout(DEFAULT_READ_TIMEOUT) + .await?; + let request_id = app_server + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: Some(child.to_string_lossy().into_owned()), + }) + .await?; + let ConfigReadResponse { config, layers, .. } = + timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??; + + assert_eq!( + ( + config.additional.get("project_root_markers"), + config.model_context_window, + config.model_reasoning_effort, + ), + (Some(&json!([])), Some(16384), Some(ReasoningEffort::High)) + ); + let project_layers = layers + .expect("layers present") + .into_iter() + .filter_map(|layer| { + if let ConfigLayerSource::Project { dot_codex_folder } = layer.name { + Some((dot_codex_folder, layer.config, layer.disabled_reason)) + } else { + None + } + }) + .collect::>(); + assert_eq!( + project_layers, + vec![( + AbsolutePathBuf::try_from(child_config)?, + json!({"model_reasoning_effort": "high"}), + None, + )] + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index 6b4129f602..9f7e869c94 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -39,6 +39,12 @@ precedence first, highest precedence last, so later layers override earlier layers when folded. Thread config entries supplied by `thread_config_loader` are inserted according to their translated `ConfigLayerSource` precedence. +Project-root discovery and project trust use the applicable non-project layers, +including legacy managed-file and MDM config at their normal precedence. The +managed layers remain above project and session layers in the final stack. +Executor-local reads use their own system, base-user, and legacy managed sources; +they do not include cloud config, selected profiles, or session flags. + Layers with a `disabled_reason` are still surfaced for UI, but are ignored when computing the effective config and origins metadata. This is what `ConfigLayerStack::effective_config()` implements. @@ -77,6 +83,7 @@ Implementation is split by concern: - `state.rs`: public types (`ConfigLayerEntry`, `ConfigLayerStack`) + merge/origins convenience methods. - `layer_io.rs`: reading `config.toml`, managed config, and managed preferences inputs. +- `project_discovery.rs`: shared managed-config composition for project-root and trust discovery. - `overrides.rs`: CLI dotted-path overrides → TOML “session flags” layer. - `merge.rs`: recursive TOML merge. - `fingerprint.rs`: stable per-layer hashing and per-key origins traversal. diff --git a/codex-rs/config/src/loader/local.rs b/codex-rs/config/src/loader/local.rs index de53b1e81a..2f57f552bf 100644 --- a/codex-rs/config/src/loader/local.rs +++ b/codex-rs/config/src/loader/local.rs @@ -3,6 +3,7 @@ use super::layer_io; use super::load_config_toml_for_required_layer_raw; use super::load_requirements_toml; use super::load_root_checkout_project_config; +use super::project_discovery; use super::project_root_markers_from_config; use super::project_trust_context; use super::requirements_layers_from_legacy_scheme; @@ -86,8 +87,8 @@ pub struct LocalTomlLayer { /// config reads. /// /// Cloud, selected profiles, session flags, and thread-provided layers are not -/// included. Project discovery uses only the executor's system and base-user -/// configuration. +/// included. Project discovery uses the executor's system, base-user, and +/// legacy managed configuration. pub async fn load_local_config_layers( fs: &dyn ExecutorFileSystem, codex_home: &Path, @@ -121,6 +122,13 @@ pub(super) async fn load_local_config_layers_with_overrides( let mut discovery_config = TomlValue::Table(toml::map::Map::new()); merge_toml_values(&mut discovery_config, &system.toml); merge_toml_values(&mut discovery_config, &user.toml); + // Managed file and MDM values also govern the project boundary and trust. + // Only this snapshot is resolved; the returned local layers stay raw. + project_discovery::merge_managed_config_for_discovery( + &mut discovery_config, + &loaded_managed, + codex_home.as_path(), + )?; let project_root_markers = project_root_markers_from_config(&discovery_config)? .unwrap_or_else(default_project_root_markers); let trust_context = project_trust_context( diff --git a/codex-rs/config/src/loader/managed_project_discovery_tests.rs b/codex-rs/config/src/loader/managed_project_discovery_tests.rs new file mode 100644 index 0000000000..c74d8f7650 --- /dev/null +++ b/codex-rs/config/src/loader/managed_project_discovery_tests.rs @@ -0,0 +1,267 @@ +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use crate::LoaderOverrides; +use crate::NoopThreadConfigLoader; +use crate::loader::LocalConfigLayers; +use crate::loader::load_config_layers_state; +use crate::loader::local::load_local_config_layers_with_overrides; +use crate::loader::project_trust_key; +use crate::loader::tests::TestFileSystem; +use crate::merge_toml_values; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +use tempfile::TempDir; +use toml::Value as TomlValue; + +struct Fixture { + _temp: TempDir, + home: PathBuf, + repo: AbsolutePathBuf, + project: AbsolutePathBuf, + cwd: AbsolutePathBuf, + managed: PathBuf, + overrides: LoaderOverrides, +} + +impl Fixture { + fn new() -> anyhow::Result { + let temp = tempfile::tempdir()?; + let home = temp.path().join("home"); + let repo = AbsolutePathBuf::from_absolute_path(temp.path().join("repo"))?; + let project = repo.join("project"); + let cwd = project.join("child"); + std::fs::create_dir_all(&home)?; + std::fs::create_dir_all(repo.join(".git"))?; + for (dir, model) in [(&repo, "ancestor"), (&project, "project"), (&cwd, "child")] { + std::fs::create_dir_all(dir.join(".codex"))?; + std::fs::write( + dir.join(".codex/config.toml"), + format!("model = \"{model}\"\n"), + )?; + } + std::fs::write(project.join(".company-root"), "")?; + let managed = temp.path().join("managed_config.toml"); + let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed.clone()); + overrides.system_config_path = Some(temp.path().join("system.toml")); + let fixture = Self { + _temp: temp, + home, + repo, + project, + cwd, + managed, + overrides, + }; + std::fs::write(fixture.home.join("config.toml"), fixture.trust("trusted"))?; + Ok(fixture) + } + + fn trust(&self, level: &str) -> String { + let key = TomlValue::String(project_trust_key(self.repo.as_path())); + format!("[projects.{key}]\ntrust_level = \"{level}\"\n") + } + + async fn load(&self) -> anyhow::Result<(ConfigLayerStack, LocalConfigLayers)> { + let canonical = load_config_layers_state( + &TestFileSystem, + &self.home, + Some(self.cwd.clone()), + &[ + ( + "project_root_markers".into(), + toml::Value::try_from([".git"])?, + ), + ("model".into(), TomlValue::String("session".into())), + ], + self.overrides.clone(), + &NoopThreadConfigLoader, + ) + .await?; + let local = load_local_config_layers_with_overrides( + &TestFileSystem, + &self.home, + &self.cwd, + &self.overrides, + ) + .await?; + Ok((canonical, local)) + } +} + +fn assert_discovery( + canonical: &ConfigLayerStack, + local: &LocalConfigLayers, + expected_dirs: &[&AbsolutePathBuf], + markers: &[&str], +) -> anyhow::Result<()> { + let project_dirs = |sources: Vec<&ConfigLayerSource>| { + sources + .into_iter() + .filter_map(|source| match source { + ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), + _ => None, + }) + .collect::>() + }; + let expected_dirs = expected_dirs + .iter() + .map(|dir| dir.join(".codex")) + .collect::>(); + assert_eq!( + ( + project_dirs( + canonical + .layers_low_to_high() + .map(|layer| &layer.name) + .collect() + ), + project_dirs( + local + .config + .layers + .iter() + .map(|layer| &layer.source) + .collect() + ), + ), + (expected_dirs.clone(), expected_dirs) + ); + let effective = canonical.effective_config(); + let mut local_effective = TomlValue::Table(toml::map::Map::new()); + for layer in &local.config.layers { + merge_toml_values(&mut local_effective, &layer.toml); + } + let expected_markers = TomlValue::try_from(markers)?; + assert_eq!( + ( + effective.get("project_root_markers"), + local_effective.get("project_root_markers"), + ), + (Some(&expected_markers), Some(&expected_markers)) + ); + Ok(()) +} + +#[tokio::test] +async fn managed_project_discovery_uses_file_markers_without_changing_precedence() +-> anyhow::Result<()> { + let fixture = Fixture::new()?; + for (markers, dirs) in [ + ( + vec![".git"], + vec![&fixture.repo, &fixture.project, &fixture.cwd], + ), + (vec![], vec![&fixture.cwd]), + (vec![".company-root"], vec![&fixture.project, &fixture.cwd]), + ] { + let markers_toml = TomlValue::try_from(&markers)?; + std::fs::write( + &fixture.managed, + format!("project_root_markers = {markers_toml}\nmodel = \"managed\"\n"), + )?; + let (canonical, local) = fixture.load().await?; + assert_discovery(&canonical, &local, &dirs, &markers)?; + let managed_source = ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf::from_absolute_path(&fixture.managed)?, + }; + assert_eq!( + ( + canonical.effective_config().get("model").cloned(), + &canonical.origins()["model"].name, + &local.config.layers.last().expect("managed layer").source, + ), + ( + Some(TomlValue::String("managed".into())), + &managed_source, + &managed_source, + ) + ); + } + Ok(()) +} + +#[tokio::test] +async fn managed_project_discovery_uses_managed_project_trust() -> anyhow::Result<()> { + let fixture = Fixture::new()?; + let trust_key = project_trust_key(fixture.repo.as_path()); + let user_config = fixture.home.join("config.toml"); + let untrusted_reason = format!( + "{trust_key} is marked as untrusted in the effective configuration. To load project-local config, hooks, and exec policies, update its trust setting. If that setting is managed by your organization, contact your administrator." + ); + let unknown_reason = format!( + "To load project-local config, hooks, and exec policies, add {trust_key} as a trusted project in {}.", + user_config.display() + ); + for (user, managed, dirs, expected_reason) in [ + ( + fixture.trust("trusted"), + fixture.trust("untrusted"), + vec![], + Some(untrusted_reason.as_str()), + ), + ( + fixture.trust("untrusted"), + fixture.trust("trusted"), + vec![&fixture.cwd], + None, + ), + ( + String::new(), + String::new(), + vec![], + Some(unknown_reason.as_str()), + ), + ] { + std::fs::write(&user_config, user)?; + std::fs::write( + &fixture.managed, + format!("project_root_markers = []\n{managed}"), + )?; + let (canonical, local) = fixture.load().await?; + assert_discovery(&canonical, &local, &dirs, &[])?; + assert_eq!( + canonical + .all_layers_low_to_high() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .map(|layer| layer.disabled_reason.as_deref()) + .collect::>(), + vec![expected_reason] + ); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_project_discovery_mdm_overrides_file_markers() -> anyhow::Result<()> { + use base64::Engine; + use base64::prelude::BASE64_STANDARD; + + let mut fixture = Fixture::new()?; + std::fs::write(&fixture.managed, "project_root_markers = [\".git\"]\n")?; + for (markers, dirs) in [ + (vec![], vec![&fixture.cwd]), + (vec![".company-root"], vec![&fixture.project, &fixture.cwd]), + ] { + let markers_toml = TomlValue::try_from(&markers)?; + fixture.overrides.managed_preferences_base64 = + Some(BASE64_STANDARD.encode(format!("project_root_markers = {markers_toml}\n"))); + let (canonical, local) = fixture.load().await?; + assert_discovery(&canonical, &local, &dirs, &markers)?; + assert_eq!( + ( + canonical + .layers_high_to_low() + .next() + .map(|layer| &layer.name), + local.config.layers.last().map(|layer| &layer.source), + ), + ( + Some(&ConfigLayerSource::LegacyManagedConfigTomlFromMdm), + Some(&ConfigLayerSource::LegacyManagedConfigTomlFromMdm), + ) + ); + } + Ok(()) +} diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 44058d1fee..f18c74c5ae 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -2,6 +2,7 @@ mod layer_io; mod local; #[cfg(target_os = "macos")] mod macos; +mod project_discovery; #[cfg(test)] mod tests; @@ -364,6 +365,13 @@ pub async fn load_config_layers_state( if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { merge_toml_values(&mut merged_so_far, cli_overrides_layer); } + // Managed config wins over CLI config. Apply it before choosing the + // project root and trust, but keep its final layers above project config. + project_discovery::merge_managed_config_for_discovery( + &mut merged_so_far, + &loaded_config_layers, + codex_home, + )?; let project_root_markers = match project_root_markers_from_config(&merged_so_far) { Ok(markers) => markers.unwrap_or_else(default_project_root_markers), @@ -1053,9 +1061,11 @@ impl ProjectTrustContext { let gated_features = "project-local config, hooks, and exec policies"; let trust_key = decision.trust_key.as_str(); let user_config_file = self.user_config_file.as_path().display(); + // Trust may come from managed config. Keep the explicit-untrusted prefix + // stable because the remote TUI uses it to recognize existing decisions. match decision.trust_level { Some(TrustLevel::Untrusted) => Some(format!( - "{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted." + "{trust_key} is marked as untrusted in the effective configuration. To load {gated_features}, update its trust setting. If that setting is managed by your organization, contact your administrator." )), _ => Some(format!( "To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}." diff --git a/codex-rs/config/src/loader/project_discovery.rs b/codex-rs/config/src/loader/project_discovery.rs new file mode 100644 index 0000000000..bd45090c23 --- /dev/null +++ b/codex-rs/config/src/loader/project_discovery.rs @@ -0,0 +1,42 @@ +//! Shared configuration composition for project-root and trust discovery. + +use super::layer_io::LoadedConfigLayers; +use super::resolve_relative_paths_in_config_toml; +use crate::merge_toml_values; +use std::io; +use std::path::Path; +use toml::Value as TomlValue; + +/// Overlay the already-read managed sources at their normal precedence: file, +/// then MDM. Resolve paths as the final loader does, without changing the raw +/// layers or reading either source again. +pub(super) fn merge_managed_config_for_discovery( + discovery_config: &mut TomlValue, + loaded: &LoadedConfigLayers, + codex_home: &Path, +) -> io::Result<()> { + if let Some(config) = &loaded.managed_config { + let base_dir = config.file.as_path().parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Managed config file {} has no parent directory", + config.file.as_path().display() + ), + ) + })?; + let resolved = + resolve_relative_paths_in_config_toml(config.managed_config.clone(), base_dir)?; + merge_toml_values(discovery_config, &resolved); + } + if let Some(config) = &loaded.managed_config_from_mdm { + let resolved = + resolve_relative_paths_in_config_toml(config.managed_config.clone(), codex_home)?; + merge_toml_values(discovery_config, &resolved); + } + Ok(()) +} + +#[cfg(test)] +#[path = "managed_project_discovery_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index db1c3e0cf3..5cb1ad9af1 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -11,7 +11,7 @@ use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use tempfile::tempdir; -struct TestFileSystem; +pub(super) struct TestFileSystem; impl ExecutorFileSystem for TestFileSystem { fn canonicalize<'a>( diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs index b621b52e8f..1e9f588ad6 100644 --- a/codex-rs/tui/src/config_update_tests.rs +++ b/codex-rs/tui/src/config_update_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::history_cell::HistoryCell; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use codex_app_server_client::AppServerClient; @@ -108,6 +109,39 @@ async fn remote_project_trust_guards_thread_start_and_preserves_repository_decis None ); + let response: JsonValue = app_server + .request_typed(ClientRequest::ConfigRead { + request_id: RequestId::String("untrusted-project-warning".to_string()), + params: ConfigReadParams { + include_layers: true, + cwd: Some(project_cwd.to_string_lossy().into_owned()), + }, + }) + .await?; + let reason = response["layers"] + .as_array() + .expect("config layers") + .iter() + .find(|layer| layer["name"]["type"] == "project") + .and_then(|layer| layer["disabledReason"].as_str()) + .expect("explicitly untrusted project warning"); + let warning = crate::history_cell::new_warning_event( + reason.replace(&project_trust_key(&project_root), ""), + ); + insta::assert_snapshot!( + warning + .display_lines(/*width*/ 80) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + @r" + ⚠ is marked as untrusted in the effective configuration. To load + project-local config, hooks, and exec policies, update its trust setting. If + that setting is managed by your organization, contact your administrator. + " + ); + std::fs::remove_file(project_cwd.join(".codex/config.toml"))?; std::fs::remove_dir(project_cwd.join(".codex"))?; let canonical_project_cwd = PathBuf::from(project_trust_key(&project_root)).join("nested");