diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0aec959b9..7ba7cc04b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -240,6 +240,14 @@ pub enum ConfigLayerSource { file: AbsolutePathBuf, }, + /// Path to a .codex/ folder within a project. There could be multiple of + /// these between `cwd` and the project/repo root. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Project { + dot_codex_folder: AbsolutePathBuf, + }, + /// Session-layer overrides supplied via `-c`/`--config`. SessionFlags, @@ -247,6 +255,8 @@ pub enum ConfigLayerSource { /// as the last layer on top of everything else. This scheme did not quite /// work out as intended, but we keep this variant as a "best effort" while /// we phase out `managed_config.toml` in favor of `requirements.toml`. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf, }, @@ -262,6 +272,7 @@ impl ConfigLayerSource { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::Project { .. } => 25, ConfigLayerSource::SessionFlags => 30, ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b6cbb0549..f580c66a1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -43,6 +43,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use dirs::home_dir; use serde::Deserialize; +use serde::Serialize; use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; @@ -664,7 +665,7 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R } /// Base config deserialized from ~/.codex/config.toml. -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigToml { /// Optional override of model selection. pub model: Option, @@ -853,7 +854,7 @@ impl From for UserSavedConfig { } } -#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ProjectConfig { pub trust_level: Option, } @@ -868,7 +869,7 @@ impl ProjectConfig { } } -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] pub web_search: Option, @@ -887,7 +888,7 @@ impl From for Tools { } } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct GhostSnapshotToml { /// Exclude untracked files larger than this many bytes from ghost snapshots. #[serde(alias = "ignore_untracked_files_over_bytes")] diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 718d97205..900706490 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -1,5 +1,6 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Serialize; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; @@ -9,7 +10,7 @@ use codex_protocol::openai_models::ReasoningEffort; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ConfigProfile { pub model: Option, /// The key in the `model_providers` map identifying the diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 27785ff0f..b82809568 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -556,6 +556,10 @@ fn override_message(layer: &ConfigLayerSource) -> String { ConfigLayerSource::System { file } => { format!("Overridden by managed config (system): {}", file.display()) } + ConfigLayerSource::Project { dot_codex_folder } => format!( + "Overridden by project config: {}/{CONFIG_TOML_FILE}", + dot_codex_folder.display(), + ), ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(), ConfigLayerSource::User { file } => { format!("Overridden by user config: {}", file.display()) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 8439b9916..3aa72d5ce 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -221,7 +221,7 @@ mod option_duration_secs { } } -#[derive(Deserialize, Debug, Copy, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)] pub enum UriBasedFileOpener { #[serde(rename = "vscode")] VsCode, @@ -253,7 +253,7 @@ impl UriBasedFileOpener { } /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct History { /// If true, history entries will not be written to disk. pub persistence: HistoryPersistence, @@ -263,7 +263,7 @@ pub struct History { pub max_bytes: Option, } -#[derive(Deserialize, Debug, Copy, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub enum HistoryPersistence { /// Save all history entries to disk. @@ -275,7 +275,7 @@ pub enum HistoryPersistence { // ===== OTEL configuration ===== -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum OtelHttpProtocol { /// Binary payload @@ -284,7 +284,7 @@ pub enum OtelHttpProtocol { Json, } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub struct OtelTlsConfig { pub ca_certificate: Option, @@ -293,7 +293,7 @@ pub struct OtelTlsConfig { } /// Which OTEL exporter to use. -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum OtelExporterKind { None, @@ -315,7 +315,7 @@ pub enum OtelExporterKind { } /// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct OtelConfigToml { /// Log user prompt in traces pub log_user_prompt: Option, @@ -350,7 +350,7 @@ impl Default for OtelConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(untagged)] pub enum Notifications { Enabled(bool), @@ -368,7 +368,7 @@ impl Default for Notifications { /// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse /// button events, without a magnitude. This setting controls whether Codex uses a heuristic to /// infer wheel vs trackpad per stream, or forces a specific behavior. -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ScrollInputMode { /// Infer wheel vs trackpad behavior per scroll stream. @@ -386,7 +386,7 @@ impl Default for ScrollInputMode { } /// Collection of settings that are specific to the TUI. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Tui { /// Enable desktop notifications from the TUI when the terminal is unfocused. /// Defaults to `true`. @@ -514,7 +514,7 @@ const fn default_true() -> bool { /// Settings for notices we display to users via the tui and app-server clients /// (primarily the Codex IDE extension). NOTE: these are different from /// notifications - notices are warnings, NUX screens, acknowledgements, etc. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. pub hide_full_access_warning: Option, @@ -537,7 +537,7 @@ impl Notice { pub(crate) const TABLE_KEY: &'static str = "notice"; } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { #[serde(default)] pub writable_roots: Vec, @@ -560,7 +560,7 @@ impl From for codex_app_server_protocol::SandboxSettings } } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub enum ShellEnvironmentPolicyInherit { /// "Core" environment variables for the platform. On UNIX, this would @@ -577,7 +577,7 @@ pub enum ShellEnvironmentPolicyInherit { /// Policy for building the `env` when spawning a process via either the /// `shell` or `local_shell` tool. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct ShellEnvironmentPolicyToml { pub inherit: Option, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index c05825db8..6eb818955 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -11,12 +11,14 @@ mod state; mod tests; use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigToml; use crate::config_loader::config_requirements::ConfigRequirementsToml; use crate::config_loader::layer_io::LoadedConfigLayers; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::Deserialize; use std::io; use std::path::Path; @@ -109,6 +111,17 @@ pub async fn load_config_layers_state( ), ) })?; + let user_config_parent = user_file.as_path().parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "User config file {} has no parent directory", + user_file.as_path().display() + ), + ) + })?; + let user_config = + resolve_relative_paths_in_config_toml(user_config, user_config_parent)?; layers.push(ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, user_config, @@ -127,8 +140,11 @@ pub async fn load_config_layers_state( } } - // TODO(mbolin): Add layers for cwd, tree, and repo config files. - let _ = cwd; + if let Some(cwd) = cwd { + let project_root = find_project_root(&cwd).await?; + let project_layers = load_project_layers(&cwd, &project_root).await?; + layers.extend(project_layers); + } // Add a layer for runtime overrides from the CLI or UI, if any exist. if !cli_overrides.is_empty() { @@ -149,11 +165,20 @@ pub async fn load_config_layers_state( managed_config_from_mdm, } = loaded_config_layers; if let Some(config) = managed_config { + let managed_parent = 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 managed_config = + resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?; layers.push(ConfigLayerEntry::new( - ConfigLayerSource::LegacyManagedConfigTomlFromFile { - file: config.file.clone(), - }, - config.managed_config, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file }, + managed_config, )); } if let Some(config) = managed_config_from_mdm { @@ -235,6 +260,161 @@ async fn load_requirements_from_legacy_scheme( Ok(()) } +/// Takes a `toml::Value` parsed from a config.toml file and walks through it, +/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new +/// `toml::Value` with the same shape but with paths resolved. +/// +/// This ensures that multiple config layers can be merged together correctly +/// even if they were loaded from different directories. +fn resolve_relative_paths_in_config_toml( + value_from_config_toml: TomlValue, + base_dir: &Path, +) -> io::Result { + // Use the serialize/deserialize round-trip to convert the + // `toml::Value` into a `ConfigToml` with `AbsolutePath + let _guard = AbsolutePathBufGuard::new(base_dir); + let Ok(resolved) = value_from_config_toml.clone().try_into::() else { + return Ok(value_from_config_toml); + }; + drop(_guard); + + let resolved_value = TomlValue::try_from(resolved).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to serialize resolved config: {e}"), + ) + })?; + + Ok(copy_shape_from_original( + &value_from_config_toml, + &resolved_value, + )) +} + +/// Ensure that every field in `original` is present in the returned +/// `toml::Value`, taking the value from `resolved` where possible. This ensures +/// the fields that we "removed" during the serialize/deserialize round-trip in +/// `resolve_config_paths` are preserved, out of an abundance of caution. +fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue { + match (original, resolved) { + (TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => { + let mut table = toml::map::Map::new(); + for (key, original_value) in original_table { + let resolved_value = resolved_table.get(key).unwrap_or(original_value); + table.insert( + key.clone(), + copy_shape_from_original(original_value, resolved_value), + ); + } + TomlValue::Table(table) + } + (TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => { + let mut items = Vec::new(); + for (index, original_value) in original_array.iter().enumerate() { + let resolved_value = resolved_array.get(index).unwrap_or(original_value); + items.push(copy_shape_from_original(original_value, resolved_value)); + } + TomlValue::Array(items) + } + (_, resolved_value) => resolved_value.clone(), + } +} + +async fn find_project_root(cwd: &AbsolutePathBuf) -> io::Result { + for ancestor in cwd.as_path().ancestors() { + let git_dir = ancestor.join(".git"); + if tokio::fs::metadata(&git_dir).await.is_ok() { + return AbsolutePathBuf::from_absolute_path(ancestor); + } + } + Ok(cwd.clone()) +} + +/// 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, +/// starting from folders closest to `project_root` (which is the lowest +/// precedence) to those closest to `cwd` (which is the highest precedence). +async fn load_project_layers( + cwd: &AbsolutePathBuf, + project_root: &AbsolutePathBuf, +) -> io::Result> { + let mut dirs = cwd + .as_path() + .ancestors() + .scan(false, |done, a| { + if *done { + None + } else { + if a == project_root.as_path() { + *done = true; + } + Some(a) + } + }) + .collect::>(); + dirs.reverse(); + + let mut layers = Vec::new(); + for dir in dirs { + let dot_codex = dir.join(".codex"); + if !tokio::fs::metadata(&dot_codex) + .await + .map(|meta| meta.is_dir()) + .unwrap_or(false) + { + continue; + } + + let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?; + let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?; + match tokio::fs::read_to_string(&config_file).await { + Ok(contents) => { + let config: TomlValue = toml::from_str(&contents).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project config file {}: {e}", + config_file.as_path().display(), + ), + ) + })?; + let config = + resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex_abs, + }, + config, + )); + } + 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. + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex_abs, + }, + TomlValue::Table(toml::map::Map::new()), + )); + } else { + return Err(io::Error::new( + err.kind(), + format!( + "Failed to read project config file {}: {err}", + config_file.as_path().display(), + ), + )); + } + } + } + } + + Ok(layers) +} + /// 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 @@ -266,3 +446,47 @@ impl From for ConfigRequirementsToml { config_requirements_toml } } + +// Cannot name this `mod tests` because of tests.rs in this folder. +#[cfg(test)] +mod unit_tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn ensure_resolve_relative_paths_in_config_toml_preserves_all_fields() -> anyhow::Result<()> { + let tmp = tempdir()?; + let base_dir = tmp.path(); + let contents = r#" +# This is a field recognized by config.toml that is an AbsolutePathBuf in +# the ConfigToml struct. +experimental_instructions_file = "./some_file.md" + +# This is a field recognized by config.toml. +model = "gpt-1000" + +# This is a field not recognized by config.toml. +foo = "xyzzy" +"#; + let user_config: TomlValue = toml::from_str(contents)?; + + let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; + let mut expected_toml_value = toml::map::Map::new(); + expected_toml_value.insert( + "experimental_instructions_file".to_string(), + TomlValue::String( + AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)? + .as_path() + .to_string_lossy() + .to_string(), + ), + ); + expected_toml_value.insert( + "model".to_string(), + TomlValue::String("gpt-1000".to_string()), + ); + expected_toml_value.insert("foo".to_string(), TomlValue::String("xyzzy".to_string())); + assert_eq!(normalized_toml_value, TomlValue::Table(expected_toml_value)); + Ok(()) + } +} diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index 864a9c7ed..50ad8e896 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -19,7 +19,7 @@ pub struct LoaderOverrides { pub managed_preferences_base64: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct ConfigLayerEntry { pub name: ConfigLayerSource, pub config: TomlValue, @@ -170,7 +170,12 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result = None; + let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None; for (index, layer) in layers.iter().enumerate() { if matches!(layer.name, ConfigLayerSource::User { .. }) { if user_layer_index.is_some() { @@ -181,6 +186,32 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + tokio::fs::write( + project_root.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"root\"\n", + ) + .await?; + tokio::fs::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let layers = load_config_layers_state( + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter_map(|layer| match &layer.name { + super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder), + _ => None, + }) + .collect(); + assert_eq!(project_layers.len(), 2); + assert_eq!(project_layers[0].as_path(), nested.join(".codex").as_path()); + assert_eq!( + project_layers[1].as_path(), + project_root.join(".codex").as_path() + ); + + let config = layers.effective_config(); + let foo = config + .get("foo") + .and_then(TomlValue::as_str) + .expect("foo entry"); + assert_eq!(foo, "child"); + Ok(()) +} + +#[tokio::test] +async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> std::io::Result<()> +{ + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let root_cfg = r#" +experimental_instructions_file = "root.txt" +"#; + let nested_cfg = r#" +experimental_instructions_file = "child.txt" +"#; + tokio::fs::write(project_root.join(".codex").join(CONFIG_TOML_FILE), root_cfg).await?; + tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), nested_cfg).await?; + tokio::fs::write( + project_root.join(".codex").join("root.txt"), + "root instructions", + ) + .await?; + tokio::fs::write( + nested.join(".codex").join("child.txt"), + "child instructions", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .harness_overrides(ConfigOverrides { + cwd: Some(nested.clone()), + ..ConfigOverrides::default() + }) + .build() + .await?; + + assert_eq!( + config.base_instructions.as_deref(), + Some("child instructions") + ); + + Ok(()) +} + +#[tokio::test] +async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(&nested).await?; + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let layers = load_config_layers_state( + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!( + vec![&ConfigLayerEntry { + name: super::ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, + }, + config: TomlValue::Table(toml::map::Map::new()), + version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), + }], + project_layers + ); + + Ok(()) +} diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 8cece9593..242dd38c8 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -8,6 +8,7 @@ use crate::config::ConfigToml; use crate::config::profile::ConfigProfile; use serde::Deserialize; +use serde::Serialize; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -272,7 +273,7 @@ pub fn is_known_feature_key(key: &str) -> bool { } /// Deserializable features table for TOML. -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct FeaturesToml { #[serde(flatten)] pub entries: BTreeMap, diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 77d16e2d5..808e03183 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -14,7 +14,7 @@ use ts_rs::TS; /// guaranteed to be canonicalized or exist on the filesystem). /// /// IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set -/// using `AbsolutePathBufGuard::new(base_path)`. If no base path is set, the +/// using [AbsolutePathBufGuard::new]. If no base path is set, the /// deserialization will fail unless the path being deserialized is already /// absolute. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema, TS)] @@ -112,6 +112,10 @@ thread_local! { static ABSOLUTE_PATH_BASE: RefCell> = const { RefCell::new(None) }; } +/// Ensure this guard is held while deserializing `AbsolutePathBuf` values to +/// provide a base path for resolving relative paths. Because this relies on +/// thread-local storage, the deserialization must be single-threaded and +/// occur on the same thread that created the guard. pub struct AbsolutePathBufGuard; impl AbsolutePathBufGuard {