Honor managed config during project discovery (#39306)

## What changed

- Include legacy managed-file and MDM settings when resolving project root markers and project trust in both canonical and executor-local config loads.
- Preserve the existing managed-layer precedence in the final config stack and resolve managed relative paths against the same bases used by the final loader.
- Update explicit-untrusted warnings to describe the effective configuration and direct users to an administrator when the setting is managed.

## Testing

- Cover managed root markers, managed trust overrides, MDM precedence, app-server config reads, and the remote TUI warning.

GitOrigin-RevId: 9b5ddd54b4530f73589d6842a15cbab7e6d6bbb9
This commit is contained in:
joeflorencio-openai
2026-08-18 22:44:28 +00:00
committed by copyberry
parent e51a91b2f4
commit 6ec012668b
8 changed files with 445 additions and 4 deletions
@@ -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::<Vec<_>>();
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()?;
+7
View File
@@ -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.
+10 -2
View File
@@ -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<S> {
/// 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(
@@ -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<Self> {
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::<Vec<_>>()
};
let expected_dirs = expected_dirs
.iter()
.map(|dir| dir.join(".codex"))
.collect::<Vec<_>>();
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<_>>(),
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(())
}
+11 -1
View File
@@ -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}."
@@ -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;
+1 -1
View File
@@ -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>(
+34
View File
@@ -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), "<PROJECT>"),
);
insta::assert_snapshot!(
warning
.display_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
@r"
⚠ <PROJECT> 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");