diff --git a/codex-rs/config/src/loader/local.rs b/codex-rs/config/src/loader/local.rs new file mode 100644 index 0000000000..de53b1e81a --- /dev/null +++ b/codex-rs/config/src/loader/local.rs @@ -0,0 +1,366 @@ +use super::discover_project_layers; +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_root_markers_from_config; +use super::project_trust_context; +use super::requirements_layers_from_legacy_scheme; +use super::system_config_toml_file_with_overrides; +use super::system_requirements_toml_file_with_overrides; +use crate::CONFIG_TOML_FILE; +use crate::ConfigLayerSource; +use crate::LoaderOverrides; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::default_project_root_markers; +use crate::merge_toml_values; +use codex_file_system::ExecutorFileSystem; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; +use std::io; +use std::path::Path; +use toml::Value as TomlValue; + +/// Executor-local configuration and requirements layers before schema-specific +/// path resolution or requirements composition. +#[derive(Debug, Clone, PartialEq)] +pub struct LocalConfigLayers { + pub config: LocalTomlLayerStack, + pub requirements: LocalTomlLayerStack, +} + +impl LocalConfigLayers { + /// Retains only the requested TOML paths and drops empty layers. + /// + /// An empty path selects the entire document. RPC boundaries should reject + /// that form if whole-document reads are not part of their contract. + pub fn project(self, config_paths: &[Vec], requirements_paths: &[Vec]) -> Self { + Self { + config: self.config.project(config_paths), + requirements: self.requirements.project(requirements_paths), + } + } +} + +/// One ordered set of executor-local TOML layers. +#[derive(Debug, Clone, PartialEq)] +pub struct LocalTomlLayerStack { + /// Layers ordered from lowest to highest precedence. + pub layers: Vec>, + /// Position at which a caller should insert cloud-provided layers. + pub cloud_insertion_index: usize, +} + +impl LocalTomlLayerStack { + fn project(self, paths: &[Vec]) -> Self { + let selectors = SelectorNode::from_paths(paths); + let mut projected_layers = Vec::new(); + let mut cloud_insertion_index = 0; + for (index, layer) in self.layers.into_iter().enumerate() { + let Some(toml) = project_toml(&layer.toml, &selectors) else { + continue; + }; + if index < self.cloud_insertion_index { + cloud_insertion_index += 1; + } + projected_layers.push(LocalTomlLayer { toml, ..layer }); + } + Self { + layers: projected_layers, + cloud_insertion_index, + } + } +} + +/// One executor-local TOML source with the directory used to interpret its +/// relative paths. +#[derive(Debug, Clone, PartialEq)] +pub struct LocalTomlLayer { + pub source: S, + pub base_dir: AbsolutePathBuf, + pub toml: TomlValue, +} + +/// Loads the fixed executor-local configuration sources used by environment +/// 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. +pub async fn load_local_config_layers( + fs: &dyn ExecutorFileSystem, + codex_home: &Path, + cwd: &AbsolutePathBuf, +) -> io::Result { + load_local_config_layers_with_overrides(fs, codex_home, cwd, &LoaderOverrides::default()).await +} + +pub(super) async fn load_local_config_layers_with_overrides( + fs: &dyn ExecutorFileSystem, + codex_home: &Path, + cwd: &AbsolutePathBuf, + overrides: &LoaderOverrides, +) -> io::Result { + let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?; + let loaded_managed = layer_io::load_config_layers_internal( + fs, + codex_home.as_path(), + overrides.clone(), + /*strict_config*/ false, + ) + .await?; + + let system_file = system_config_toml_file_with_overrides(overrides)?; + let system = + load_config_toml_for_required_layer_raw(fs, &system_file, /*strict_config*/ false).await?; + let user_file = codex_home.join(CONFIG_TOML_FILE); + let user = + load_config_toml_for_required_layer_raw(fs, &user_file, /*strict_config*/ false).await?; + + 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); + let project_root_markers = project_root_markers_from_config(&discovery_config)? + .unwrap_or_else(default_project_root_markers); + let trust_context = project_trust_context( + fs, + &discovery_config, + cwd, + &project_root_markers, + codex_home.as_path(), + &user_file, + ) + .await?; + let project_layers = discover_project_layers( + fs, + cwd, + &trust_context.project_root, + &trust_context, + codex_home.as_path(), + /*strict_config*/ false, + ) + .await?; + + let mut config_layers = vec![ + LocalTomlLayer { + source: ConfigLayerSource::System { file: system_file }, + base_dir: system.base_dir, + toml: system.toml, + }, + LocalTomlLayer { + source: ConfigLayerSource::User { + file: user_file, + profile: None, + }, + base_dir: user.base_dir, + toml: user.toml, + }, + ]; + append_project_layers(fs, &mut config_layers, project_layers.layers).await?; + + let requirements = + local_requirements_layers(fs, codex_home.as_path(), overrides, loaded_managed.clone()) + .await?; + append_legacy_config_layers(&mut config_layers, loaded_managed, &codex_home)?; + + Ok(LocalConfigLayers { + config: LocalTomlLayerStack { + layers: config_layers, + // Cloud config follows the required system layer. + cloud_insertion_index: 1, + }, + requirements, + }) +} + +async fn append_project_layers( + fs: &dyn ExecutorFileSystem, + output: &mut Vec>, + layers: Vec, +) -> io::Result<()> { + for layer in layers { + if layer.disabled_reason.is_some() { + continue; + } + let mut config = layer.config; + if layer.hooks_config_folder_override.is_some() + && let Some(table) = config.as_table_mut() + { + table.remove("hooks"); + } + output.push(LocalTomlLayer { + source: ConfigLayerSource::Project { + dot_codex_folder: layer.dot_codex_folder.clone(), + }, + base_dir: layer.dot_codex_folder, + toml: config, + }); + + let Some(hooks_config_folder) = layer.hooks_config_folder_override else { + continue; + }; + let root_config = + load_root_checkout_project_config(fs, &hooks_config_folder, /*is_trusted*/ true) + .await?; + let Some(hooks) = root_config.get("hooks") else { + continue; + }; + output.push(LocalTomlLayer { + source: ConfigLayerSource::Project { + dot_codex_folder: hooks_config_folder.clone(), + }, + base_dir: hooks_config_folder, + toml: TomlValue::Table(toml::map::Map::from_iter([( + "hooks".to_string(), + hooks.clone(), + )])), + }); + } + Ok(()) +} + +fn append_legacy_config_layers( + output: &mut Vec>, + loaded: layer_io::LoadedConfigLayers, + codex_home: &AbsolutePathBuf, +) -> io::Result<()> { + if let Some(config) = loaded.managed_config { + let base_dir = config.file.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Managed config file {} has no parent directory", + config.file.as_path().display() + ), + ) + })?; + output.push(LocalTomlLayer { + source: ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file }, + base_dir, + toml: config.managed_config, + }); + } + if let Some(config) = loaded.managed_config_from_mdm { + output.push(LocalTomlLayer { + source: ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + base_dir: codex_home.clone(), + toml: config.managed_config, + }); + } + Ok(()) +} + +async fn local_requirements_layers( + fs: &dyn ExecutorFileSystem, + codex_home: &Path, + overrides: &LoaderOverrides, + loaded_managed: layer_io::LoadedConfigLayers, +) -> io::Result> { + let system_file = system_requirements_toml_file_with_overrides(overrides)?; + let system = load_requirements_toml(fs, &system_file).await?; + let cloud_insertion_index = usize::from(system.is_some()); + let mut entries = Vec::new(); + entries.extend(system); + entries.extend(requirements_layers_from_legacy_scheme( + loaded_managed, + codex_home, + )?); + + #[cfg(target_os = "macos")] + { + let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?; + entries.extend( + super::macos::load_managed_admin_requirements_layer( + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await? + .map(|layer| layer.with_base_dir(codex_home)), + ); + } + + let mut layers = Vec::with_capacity(entries.len()); + for entry in entries { + layers.push(local_requirements_layer(entry)?); + } + Ok(LocalTomlLayerStack { + layers, + cloud_insertion_index, + }) +} + +fn local_requirements_layer( + entry: RequirementsLayerEntry, +) -> io::Result> { + let (source, toml, base_dir) = entry.into_raw_parts()?; + let base_dir = base_dir.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("requirements layer {source} has no base directory"), + ) + })?; + Ok(LocalTomlLayer { + source, + base_dir, + toml, + }) +} + +#[derive(Default)] +struct SelectorNode { + terminal: bool, + children: BTreeMap, +} + +impl SelectorNode { + fn from_paths(paths: &[Vec]) -> Self { + let mut root = Self::default(); + for path in paths { + root.insert(path); + } + root + } + + fn insert(&mut self, path: &[String]) { + if self.terminal { + return; + } + let Some((segment, remaining)) = path.split_first() else { + self.terminal = true; + self.children.clear(); + return; + }; + self.children + .entry(segment.clone()) + .or_default() + .insert(remaining); + } +} + +fn project_toml(value: &TomlValue, selector: &SelectorNode) -> Option { + let projected = project_toml_value(value, selector); + if !selector.terminal && projected.as_table().is_some_and(toml::map::Map::is_empty) { + return None; + } + Some(projected) +} + +fn project_toml_value(value: &TomlValue, selector: &SelectorNode) -> TomlValue { + if selector.terminal { + return value.clone(); + } + let Some(table) = value.as_table() else { + // Preserve a non-table ancestor so it can still override lower layers. + return value.clone(); + }; + let mut projected = toml::map::Map::new(); + for (key, value) in table { + let Some(child_selector) = selector.children.get(key) else { + continue; + }; + projected.insert(key.clone(), project_toml_value(value, child_selector)); + } + TomlValue::Table(projected) +} diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 63df1ec150..8d01ec755c 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1,4 +1,5 @@ mod layer_io; +mod local; #[cfg(target_os = "macos")] mod macos; #[cfg(test)] @@ -51,6 +52,11 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; +pub use local::LocalConfigLayers; +pub use local::LocalTomlLayer; +pub use local::LocalTomlLayerStack; +pub use local::load_local_config_layers; + #[cfg(unix)] const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; @@ -188,6 +194,7 @@ pub async fn load_config_layers_state( // requirements layers for backwards compatibility. requirements_layers.extend(requirements_layers_from_legacy_scheme( loaded_config_layers.clone(), + codex_home, )?); requirements_layers.extend(managed_preferences_requirements_layer); } @@ -499,18 +506,36 @@ async fn load_config_toml_for_required_layer( strict_config: bool, create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, ) -> io::Result { + let loaded = load_config_toml_for_required_layer_raw(fs, toml_file, strict_config).await?; + let toml_value = resolve_relative_paths_in_config_toml(loaded.toml, loaded.base_dir.as_path())?; + + Ok(create_entry(toml_value)) +} + +#[derive(Debug, Clone)] +struct LoadedTomlFile { + toml: TomlValue, + base_dir: AbsolutePathBuf, +} + +async fn load_config_toml_for_required_layer_raw( + fs: &dyn ExecutorFileSystem, + toml_file: &AbsolutePathBuf, + strict_config: bool, +) -> io::Result { + let config_parent = toml_file.as_path().parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Config file {} has no parent directory", + toml_file.as_path().display() + ), + ) + })?; + let base_dir = AbsolutePathBuf::from_absolute_path(config_parent)?; let toml_file_uri = PathUri::from_abs_path(toml_file); let toml_value = match fs.read_file_text(&toml_file_uri, /*sandbox*/ None).await { Ok(contents) => { - let config_parent = toml_file.as_path().parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Config file {} has no parent directory", - toml_file.as_path().display() - ), - ) - })?; let config: TomlValue = toml::from_str(&contents).map_err(|err| { let config_error = config_error_from_toml(toml_file.as_path(), &contents, err.clone()); @@ -524,7 +549,7 @@ async fn load_config_toml_for_required_layer( config_parent, )?; } - resolve_relative_paths_in_config_toml(config, config_parent) + Ok(config) } Err(e) => { if e.kind() == io::ErrorKind::NotFound { @@ -541,7 +566,10 @@ async fn load_config_toml_for_required_layer( } }?; - Ok(create_entry(toml_value)) + Ok(LoadedTomlFile { + toml: toml_value, + base_dir, + }) } fn validate_config_toml_strictly( @@ -750,6 +778,7 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { fn requirements_layers_from_legacy_scheme( loaded_config_layers: LoadedConfigLayers, + codex_home: &Path, ) -> io::Result> { // List the file-backed legacy layer first because requirements layers are // composed lowest-precedence to highest-precedence, and MDM has higher @@ -762,18 +791,31 @@ fn requirements_layers_from_legacy_scheme( let layer_count = usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some()); let mut layers = Vec::with_capacity(layer_count); - for (source, config) in managed_config + let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?; + for (source, config, base_dir) in managed_config .map(|c| { - ( + let base_dir = c.file.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Managed config file {} has no parent directory", + c.file.as_path().display() + ), + ) + })?; + Ok::<_, io::Error>(( RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, c.managed_config, - ) + base_dir, + )) }) + .transpose()? .into_iter() .chain(managed_config_from_mdm.map(|config| { ( RequirementSource::LegacyManagedConfigTomlFromMdm, config.managed_config, + codex_home.clone(), ) })) { @@ -785,10 +827,13 @@ fn requirements_layers_from_legacy_scheme( ) })?; - layers.push(RequirementsLayerEntry::from_toml_value( - source, - legacy_requirements_to_toml_value(legacy_config)?, - )); + layers.push( + RequirementsLayerEntry::from_toml_value( + source, + legacy_requirements_to_toml_value(legacy_config)?, + ) + .with_base_dir(base_dir), + ); } Ok(layers) @@ -1212,6 +1257,20 @@ struct LoadedProjectLayers { startup_warnings: Vec, } +#[derive(Debug, Clone)] +struct DiscoveredProjectLayer { + dot_codex_folder: AbsolutePathBuf, + config: TomlValue, + disabled_reason: Option, + hooks_config_folder_override: Option, + load_root_checkout_hooks: bool, +} + +struct DiscoveredProjectLayers { + layers: Vec, + startup_warnings: Vec, +} + /// Return the appropriate list of layers (each with /// [ConfigLayerSource::Project] as the source) between `cwd` and /// `project_root`, inclusive. The list is ordered in _increasing_ precdence, @@ -1226,6 +1285,52 @@ async fn load_project_layers( codex_home: &Path, strict_config: bool, ) -> io::Result { + let discovered = discover_project_layers( + fs, + cwd, + project_root, + trust_context, + codex_home, + strict_config, + ) + .await?; + let mut layers = Vec::with_capacity(discovered.layers.len()); + for layer in discovered.layers { + let config = + resolve_relative_paths_in_config_toml(layer.config, layer.dot_codex_folder.as_path())?; + let config = if layer.load_root_checkout_hooks { + merge_root_checkout_project_hooks( + fs, + config, + layer.hooks_config_folder_override.as_ref(), + layer.disabled_reason.is_none(), + ) + .await? + } else { + config + }; + layers.push(project_layer_entry( + &layer.dot_codex_folder, + config, + layer.disabled_reason, + layer.hooks_config_folder_override, + )); + } + + Ok(LoadedProjectLayers { + layers, + startup_warnings: discovered.startup_warnings, + }) +} + +async fn discover_project_layers( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, + project_root: &AbsolutePathBuf, + trust_context: &ProjectTrustContext, + codex_home: &Path, + strict_config: bool, +) -> io::Result { let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?; let codex_home_normalized = normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf()); @@ -1282,12 +1387,13 @@ async fn load_project_layers( ), )); } - layers.push(project_layer_entry( - &dot_codex_abs, - TomlValue::Table(toml::map::Map::new()), - disabled_reason.clone(), - hooks_config_folder_override.clone(), - )); + layers.push(DiscoveredProjectLayer { + dot_codex_folder: dot_codex_abs, + config: TomlValue::Table(toml::map::Map::new()), + disabled_reason, + hooks_config_folder_override, + load_root_checkout_hooks: false, + }); continue; } }; @@ -1301,47 +1407,32 @@ async fn load_project_layers( )?; } let ignored_project_config_keys = sanitize_project_config(&mut config); - let config = - resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; - let config = merge_root_checkout_project_hooks( - fs, - config, - hooks_config_folder_override.as_ref(), - decision.is_trusted(), - ) - .await?; if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() { startup_warnings.push(project_ignored_config_keys_warning( &dot_codex_abs, &ignored_project_config_keys, )); } - let entry = project_layer_entry( - &dot_codex_abs, + layers.push(DiscoveredProjectLayer { + dot_codex_folder: dot_codex_abs, config, - disabled_reason.clone(), - hooks_config_folder_override.clone(), - ); - layers.push(entry); + disabled_reason, + hooks_config_folder_override, + load_root_checkout_hooks: true, + }); } Err(err) => { if err.kind() == io::ErrorKind::NotFound { // If there is no config.toml file, record an empty entry // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. - let config = merge_root_checkout_project_hooks( - fs, - TomlValue::Table(toml::map::Map::new()), - hooks_config_folder_override.as_ref(), - decision.is_trusted(), - ) - .await?; - layers.push(project_layer_entry( - &dot_codex_abs, - config, + layers.push(DiscoveredProjectLayer { + dot_codex_folder: dot_codex_abs, + config: TomlValue::Table(toml::map::Map::new()), disabled_reason, hooks_config_folder_override, - )); + load_root_checkout_hooks: true, + }); } else { let config_file_display = config_file.as_path().display(); return Err(io::Error::new( @@ -1353,7 +1444,7 @@ async fn load_project_layers( } } - Ok(LoadedProjectLayers { + Ok(DiscoveredProjectLayers { layers, startup_warnings, }) @@ -1370,43 +1461,10 @@ async fn merge_root_checkout_project_hooks( let Some(hooks_config_folder) = hooks_config_folder_override else { return Ok(config); }; - let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE); - let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file); - let root_config = match fs - .read_file_text(&hooks_config_file_uri, /*sandbox*/ None) - .await - { - Ok(contents) => { - let parsed: TomlValue = match toml::from_str(&contents) { - Ok(parsed) => parsed, - Err(err) => { - if is_trusted { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing project hooks config file {}: {err}", - hooks_config_file.as_path().display() - ), - )); - } - TomlValue::Table(toml::map::Map::new()) - } - }; - resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())? - } - Err(err) if err.kind() == io::ErrorKind::NotFound => { - TomlValue::Table(toml::map::Map::new()) - } - Err(err) => { - return Err(io::Error::new( - err.kind(), - format!( - "Failed to read project hooks config file {}: {err}", - hooks_config_file.as_path().display() - ), - )); - } - }; + let root_config = + load_root_checkout_project_config(fs, hooks_config_folder, is_trusted).await?; + let root_config = + resolve_relative_paths_in_config_toml(root_config, hooks_config_folder.as_path())?; let Some(config_table) = config.as_table_mut() else { return Ok(config); @@ -1417,6 +1475,52 @@ async fn merge_root_checkout_project_hooks( } Ok(config) } + +async fn load_root_checkout_project_config( + fs: &dyn ExecutorFileSystem, + hooks_config_folder: &AbsolutePathBuf, + is_trusted: bool, +) -> io::Result { + let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE); + let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file); + Ok( + match fs + .read_file_text(&hooks_config_file_uri, /*sandbox*/ None) + .await + { + Ok(contents) => { + let parsed: TomlValue = match toml::from_str(&contents) { + Ok(parsed) => parsed, + Err(err) => { + if is_trusted { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project hooks config file {}: {err}", + hooks_config_file.as_path().display() + ), + )); + } + TomlValue::Table(toml::map::Map::new()) + } + }; + parsed + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + TomlValue::Table(toml::map::Map::new()) + } + Err(err) => { + return Err(io::Error::new( + err.kind(), + format!( + "Failed to read project hooks config file {}: {err}", + hooks_config_file.as_path().display() + ), + )); + } + }, + ) +} /// The legacy mechanism for specifying admin-enforced configuration is to read /// from a file like `/etc/codex/managed_config.toml` that has the same /// structure as `config.toml` where fields like `approval_policy` can specify diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 068e29aa6e..15a4707401 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -70,10 +70,21 @@ impl ExecutorFileSystem for TestFileSystem { fn get_metadata<'a>( &'a self, - _path: &'a PathUri, + path: &'a PathUri, _sandbox: Option<&'a FileSystemSandboxContext>, ) -> ExecutorFileSystemFuture<'a, FileMetadata> { - Box::pin(async move { unimplemented!("test filesystem only supports reads") }) + Box::pin(async move { + let path = path.to_abs_path()?; + let metadata = tokio::fs::symlink_metadata(path.as_path()).await?; + Ok(FileMetadata { + is_directory: metadata.is_dir(), + is_file: metadata.is_file(), + is_symlink: metadata.file_type().is_symlink(), + size: metadata.len(), + created_at_ms: 0, + modified_at_ms: 0, + }) + }) } fn read_directory<'a>( @@ -254,3 +265,204 @@ model = "gpt-dev" .await .expect("profile-v2 should allow unrelated legacy profiles in base user config"); } + +#[test] +fn local_layer_projection_preserves_override_blockers_and_cloud_position() { + let tmp = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute base"); + let layer = |source, contents| LocalTomlLayer { + source, + base_dir: base_dir.clone(), + toml: toml::from_str(contents).expect("valid TOML"), + }; + let layers = LocalConfigLayers { + config: LocalTomlLayerStack { + layers: vec![ + layer( + ConfigLayerSource::System { + file: base_dir.join("system.toml"), + }, + r#"ignored=true + "literal.key"="literal" + array=[1,2] + [a] + b=1 + c=2 + "#, + ), + layer( + ConfigLayerSource::SessionFlags, + "a=2\nignored=false\nonly_user=true", + ), + layer( + ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + "[a]\nunrequested=true", + ), + ], + cloud_insertion_index: 1, + }, + requirements: LocalTomlLayerStack { + layers: Vec::>::new(), + cloud_insertion_index: 0, + }, + }; + + let only_user = layers.clone().project(&[vec!["only_user".into()]], &[]); + assert_eq!(only_user.config.layers.len(), 1); + assert_eq!(only_user.config.cloud_insertion_index, 0); + + let projected = layers.project( + &[ + vec!["a".into(), "b".into()], + vec!["array".into(), "unused".into()], + vec!["literal.key".into()], + ], + &[], + ); + + assert_eq!( + projected.config, + LocalTomlLayerStack { + layers: vec![ + layer( + ConfigLayerSource::System { + file: base_dir.join("system.toml"), + }, + r#""literal.key"="literal" + array=[1,2] + [a] + b=1"#, + ), + layer(ConfigLayerSource::SessionFlags, "a=2"), + layer(ConfigLayerSource::LegacyManagedConfigTomlFromMdm, "[a]"), + ], + cloud_insertion_index: 1, + } + ); + + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in projected.config.layers { + merge_toml_values(&mut merged, &layer.toml); + } + assert_eq!( + merged.get("a"), + Some(&TomlValue::Table(toml::map::Map::new())) + ); +} + +#[tokio::test] +async fn local_layers_keep_raw_paths_order_and_legacy_requirements() { + let tmp = tempdir().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let project = tmp.path().join("project"); + let dot_codex = project.join(".codex"); + let system_dir = tmp.path().join("system"); + let managed_dir = tmp.path().join("managed"); + for dir in [&codex_home, &dot_codex, &system_dir, &managed_dir] { + std::fs::create_dir_all(dir).expect("create fixture directory"); + } + std::fs::write(project.join(".project-root"), "").expect("write project marker"); + + let project_key = project_trust_key(&project); + let project_key = TomlValue::String(project_key).to_string(); + let user_config = |trust_level| { + format!( + "project_root_markers=[\".project-root\"]\nmodel_instructions_file=\"./user.md\"\n[projects.{project_key}]\ntrust_level=\"{trust_level}\"" + ) + }; + let user_file = codex_home.join(CONFIG_TOML_FILE); + std::fs::write(&user_file, user_config("trusted")).expect("write user config"); + let system_file = system_dir.join(CONFIG_TOML_FILE); + std::fs::write(&system_file, "model_instructions_file = \"./system.md\"") + .expect("write system config"); + std::fs::write( + dot_codex.join(CONFIG_TOML_FILE), + "model_instructions_file = \"./project.md\"\nopenai_base_url = \"https://ignored\"", + ) + .expect("write project config"); + let managed_file = managed_dir.join("managed_config.toml"); + std::fs::write( + &managed_file, + "approval_policy = \"never\"\nsandbox_mode = \"workspace-write\"\nmodel_instructions_file = \"./managed.md\"", + ) + .expect("write legacy managed config"); + let requirements_file = managed_dir.join("requirements.toml"); + std::fs::write( + &requirements_file, + "allowed_sandbox_modes = [\"read-only\"]\nlog_dir = \"./logs\"", + ) + .expect("write system requirements"); + + let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_file.clone()); + overrides.system_config_path = Some(system_file.clone()); + overrides.system_requirements_path = Some(requirements_file.clone()); + let cwd = AbsolutePathBuf::from_absolute_path(&project).expect("absolute cwd"); + let layers = local::load_local_config_layers_with_overrides( + &TestFileSystem, + &codex_home, + &cwd, + &overrides, + ) + .await + .expect("load local layers"); + + assert_eq!( + layers + .config + .layers + .iter() + .map(|layer| layer.base_dir.to_path_buf()) + .collect::>(), + vec![ + system_dir.clone(), + codex_home.clone(), + dot_codex.clone(), + managed_dir.clone(), + ] + ); + assert_eq!(layers.config.cloud_insertion_index, 1); + assert_eq!(layers.requirements.cloud_insertion_index, 1); + assert_eq!( + ( + layers.config.layers[2].toml.clone(), + layers.config.layers[3] + .toml + .get("model_instructions_file") + .cloned(), + layers.requirements.layers[0] + .toml + .get("log_dir") + .cloned(), + layers.requirements.layers[1].toml.clone(), + ), + ( + toml::from_str("model_instructions_file = \"./project.md\"") + .expect("project TOML"), + Some(TomlValue::String("./managed.md".into())), + Some(TomlValue::String("./logs".into())), + toml::from_str( + "allowed_approval_policies=[\"never\"]\nallowed_sandbox_modes=[\"read-only\",\"workspace-write\"]" + ) + .expect("legacy requirements TOML"), + ) + ); + + std::fs::write(&user_file, user_config("untrusted")).expect("write user config"); + let layers = local::load_local_config_layers_with_overrides( + &TestFileSystem, + &codex_home, + &cwd, + &overrides, + ) + .await + .expect("load local layers"); + assert_eq!( + layers + .config + .layers + .iter() + .filter(|layer| matches!(layer.source, ConfigLayerSource::Project { .. })) + .count(), + 0 + ); +} diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index c969305ebf..8c16eb4824 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -36,6 +36,19 @@ impl RequirementsLayerEntry { self.base_dir = Some(base_dir); self } + + pub(crate) fn into_raw_parts( + self, + ) -> Result<(RequirementSource, TomlValue, Option), RequirementsCompositionError> + { + let Self { + source, + toml, + base_dir, + } = self; + let toml = parse_layer_toml(&toml, &source)?; + Ok((source, toml, base_dir)) + } } #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 8ad9b0c4ff..59658c208a 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -2748,6 +2748,52 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h Ok(()) } +#[tokio::test] +async fn malformed_untrusted_linked_worktree_does_not_read_root_hooks() -> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let worktree_root = tmp.path().join("worktree"); + + tokio::fs::create_dir_all(worktree_root.join(".codex")).await?; + tokio::fs::create_dir_all(repo_root.join(".codex")).await?; + write_linked_worktree_pointer(&repo_root, &worktree_root).await?; + tokio::fs::write(worktree_root.join(".codex").join(CONFIG_TOML_FILE), "foo =").await?; + tokio::fs::write(repo_root.join(".codex").join(CONFIG_TOML_FILE), [0xff]).await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &repo_root, + TrustLevel::Untrusted, + /*project_root_markers*/ None, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&worktree_root)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + let project_layers = layers + .all_layers_high_to_low() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect::>(); + assert_eq!(project_layers.len(), 1); + assert!(project_layers[0].disabled_reason.is_some()); + assert_eq!( + project_layers[0].config, + TomlValue::Table(toml::map::Map::new()) + ); + + Ok(()) +} + #[tokio::test] async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_config_toml() -> std::io::Result<()> {