From 90cd4f3557bd3237f6fe1e24b893041e56a930c4 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 16 Apr 2026 13:08:32 -0700 Subject: [PATCH 1/8] Pass filesystem abstraction into config loading --- .../app-server/src/codex_message_processor.rs | 1 + codex-rs/core/src/codex.rs | 2 + codex-rs/core/src/config/config_tests.rs | 3 + codex-rs/core/src/config/mod.rs | 4 + codex-rs/core/src/config/service.rs | 2 + codex-rs/core/src/config_loader/README.md | 10 ++- codex-rs/core/src/config_loader/layer_io.rs | 20 +++-- codex-rs/core/src/config_loader/mod.rs | 82 +++++++++++-------- codex-rs/core/src/config_loader/tests.rs | 35 +++++++- codex-rs/core/src/network_proxy_loader.rs | 2 + 10 files changed, 114 insertions(+), 47 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 410f22ece8b1..612ef2f6f07a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6108,6 +6108,7 @@ impl CodexMessageProcessor { } }; let config_layer_stack = match load_config_layers_state( + LOCAL_FS.as_ref(), &self.config.codex_home, Some(cwd_abs.clone()), &cli_overrides, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5274d8866410..43673de6a897 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4987,6 +4987,7 @@ mod handlers { use crate::realtime_context::truncate_realtime_text_to_token_budget; use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX; use crate::realtime_conversation::prefix_realtime_v2_text; + use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; @@ -5505,6 +5506,7 @@ mod handlers { } }; let config_layer_stack = match load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd_abs.clone()), empty_cli_overrides, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 34002b129a7d..5b178ed47626 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -44,6 +44,7 @@ use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; use codex_config::types::TuiNotificationSettings; +use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeaturesToml; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; @@ -2065,6 +2066,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), codex_home.path(), Some(cwd), &Vec::new(), @@ -2198,6 +2200,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), codex_home.path(), Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c4217bdf2ac8..c953dfecfb17 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,6 +47,7 @@ use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -689,6 +690,7 @@ impl ConfigBuilder { }; harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &cli_overrides, @@ -849,6 +851,7 @@ pub async fn load_config_as_toml_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, ) -> std::io::Result { let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), codex_home, cwd.cloned(), &cli_overrides, @@ -1019,6 +1022,7 @@ pub async fn load_global_mcp_servers( // MCP servers defined in in-repo .codex/ folders. let cwd: Option = None; let config_layer_stack = load_config_layers_state( + LOCAL_FS.as_ref(), codex_home, cwd, &cli_overrides, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 6c7f071f2b93..51288e60b96d 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -29,6 +29,7 @@ use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_config::CONFIG_TOML_FILE; use codex_config::config_toml::ConfigToml; +use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::borrow::Cow; @@ -424,6 +425,7 @@ impl ConfigService { async fn load_thread_agnostic_config(&self) -> std::io::Result { let cwd: Option = None; load_config_layers_state( + LOCAL_FS.as_ref(), &self.codex_home, cwd, &self.cli_overrides, diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index 04b72e4ca12e..44a514a10adb 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_core::config_loader`: -- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -38,18 +38,22 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_core::config_loader::{load_config_layers_state, LoaderOverrides}; +use codex_core::config_loader::{ + CloudRequirementsLoader, LoaderOverrides, load_config_layers_state, +}; +use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; let cli_overrides: Vec<(String, TomlValue)> = Vec::new(); let cwd = AbsolutePathBuf::current_dir()?; let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &cli_overrides, LoaderOverrides::default(), - None, + CloudRequirementsLoader::default(), ).await?; let effective = layers.effective_config(); diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index af77bdafa54c..6bd9a9130f36 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -5,11 +5,11 @@ use super::macos::ManagedAdminConfigLayer; use super::macos::load_managed_admin_config_layer; use codex_config::config_error_from_toml; use codex_config::io_error_from_config_error; +use codex_exec_server::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use std::io; use std::path::Path; use std::path::PathBuf; -use tokio::fs; use toml::Value as TomlValue; #[cfg(unix)] @@ -36,6 +36,7 @@ pub(super) struct LoadedConfigLayers { } pub(super) async fn load_config_layers_internal( + fs: &dyn ExecutorFileSystem, codex_home: &Path, overrides: LoaderOverrides, ) -> io::Result { @@ -57,7 +58,7 @@ pub(super) async fn load_config_layers_internal( )?; let managed_config = - read_config_from_path(&managed_config_path, /*log_missing_as_info*/ false) + read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false) .await? .map(|managed_config| MangedConfigFromFile { managed_config, @@ -89,15 +90,16 @@ fn map_managed_admin_layer(layer: ManagedAdminConfigLayer) -> ManagedConfigFromM } pub(super) async fn read_config_from_path( - path: impl AsRef, + fs: &dyn ExecutorFileSystem, + path: &AbsolutePathBuf, log_missing_as_info: bool, ) -> io::Result> { - match fs::read_to_string(path.as_ref()).await { + match fs.read_file_text(path, /*sandbox*/ None).await { Ok(contents) => match toml::from_str::(&contents) { Ok(value) => Ok(Some(value)), Err(err) => { - tracing::error!("Failed to parse {}: {err}", path.as_ref().display()); - let config_error = config_error_from_toml(path.as_ref(), &contents, err.clone()); + tracing::error!("Failed to parse {}: {err}", path.as_path().display()); + let config_error = config_error_from_toml(path.as_path(), &contents, err.clone()); Err(io_error_from_config_error( io::ErrorKind::InvalidData, config_error, @@ -107,14 +109,14 @@ pub(super) async fn read_config_from_path( }, Err(err) if err.kind() == io::ErrorKind::NotFound => { if log_missing_as_info { - tracing::info!("{} not found, using defaults", path.as_ref().display()); + tracing::info!("{} not found, using defaults", path.as_path().display()); } else { - tracing::debug!("{} not found", path.as_ref().display()); + tracing::debug!("{} not found", path.as_path().display()); } Ok(None) } Err(err) => { - tracing::error!("Failed to read {}: {err}", path.as_ref().display()); + tracing::error!("Failed to read {}: {err}", path.as_path().display()); Err(err) } } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index f7830b7089f1..9f30595a89c0 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -11,6 +11,7 @@ use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigRequirementsWithSources; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; +use codex_exec_server::ExecutorFileSystem; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; @@ -118,6 +119,7 @@ pub(crate) async fn first_layer_config_error_from_entries( /// thread-agnostic config loading (e.g., for the app server's `/config` /// endpoint) should `cwd` be `None`. pub async fn load_config_layers_state( + fs: &dyn ExecutorFileSystem, codex_home: &Path, cwd: Option, cli_overrides: &[(String, TomlValue)], @@ -142,11 +144,12 @@ pub async fn load_config_layers_state( // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file()?; - load_requirements_toml(&mut config_requirements_toml, requirements_toml_file).await?; + load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. - let loaded_config_layers = layer_io::load_config_layers_internal(codex_home, overrides).await?; + let loaded_config_layers = + layer_io::load_config_layers_internal(fs, codex_home, overrides).await?; load_requirements_from_legacy_scheme( &mut config_requirements_toml, loaded_config_layers.clone(), @@ -173,7 +176,7 @@ pub async fn load_config_layers_state( // if it exists. let system_config_toml_file = system_config_toml_file()?; let system_layer = - load_config_toml_for_required_layer(&system_config_toml_file, |config_toml| { + load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::System { file: system_config_toml_file.clone(), @@ -188,7 +191,7 @@ pub async fn load_config_layers_state( // exists, but is malformed, then this error should be propagated to the // user. let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); - let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| { + let user_layer = load_config_toml_for_required_layer(fs, &user_file, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), @@ -222,6 +225,7 @@ pub async fn load_config_layers_state( } }; let project_trust_context = match project_trust_context( + fs, &merged_so_far, &cwd, &project_root_markers, @@ -247,6 +251,7 @@ pub async fn load_config_layers_state( } }; let project_layers = load_project_layers( + fs, &cwd, &project_trust_context.project_root, &project_trust_context, @@ -320,22 +325,23 @@ pub async fn load_config_layers_state( /// - If there is an error reading the file or parsing the TOML, returns an /// error. async fn load_config_toml_for_required_layer( - config_toml: impl AsRef, + fs: &dyn ExecutorFileSystem, + toml_file: &AbsolutePathBuf, create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, ) -> io::Result { - let toml_file = config_toml.as_ref(); - let toml_value = match tokio::fs::read_to_string(toml_file).await { + let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await { Ok(contents) => { let config: TomlValue = toml::from_str(&contents).map_err(|err| { - let config_error = config_error_from_toml(toml_file, &contents, err.clone()); + let config_error = + config_error_from_toml(toml_file.as_path(), &contents, err.clone()); io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) })?; - let config_parent = toml_file.parent().ok_or_else(|| { + 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.display() + toml_file.as_path().display() ), ) })?; @@ -347,7 +353,10 @@ async fn load_config_toml_for_required_layer( } else { Err(io::Error::new( e.kind(), - format!("Failed to read config file {}: {e}", toml_file.display()), + format!( + "Failed to read config file {}: {e}", + toml_file.as_path().display() + ), )) } } @@ -360,12 +369,14 @@ async fn load_config_toml_for_required_layer( /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. async fn load_requirements_toml( + fs: &dyn ExecutorFileSystem, config_requirements_toml: &mut ConfigRequirementsWithSources, - requirements_toml_file: impl AsRef, + requirements_toml_file: &AbsolutePathBuf, ) -> io::Result<()> { - let requirements_toml_file = - AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?; - match tokio::fs::read_to_string(&requirements_toml_file).await { + match fs + .read_file_text(requirements_toml_file, /*sandbox*/ None) + .await + { Ok(contents) => { let requirements_config: ConfigRequirementsToml = toml::from_str(&contents).map_err(|e| { @@ -373,7 +384,7 @@ async fn load_requirements_toml( io::ErrorKind::InvalidData, format!( "Error parsing requirements file {}: {e}", - requirements_toml_file.as_ref().display(), + requirements_toml_file.as_path().display(), ), ) })?; @@ -390,7 +401,7 @@ async fn load_requirements_toml( e.kind(), format!( "Failed to read requirements file {}: {e}", - requirements_toml_file.as_ref().display(), + requirements_toml_file.as_path().display(), ), )); } @@ -632,6 +643,7 @@ fn project_layer_entry( } async fn project_trust_context( + fs: &dyn ExecutorFileSystem, merged_config: &TomlValue, cwd: &AbsolutePathBuf, project_root_markers: &[String], @@ -646,7 +658,7 @@ async fn project_trust_context( .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))? }; - let project_root = find_project_root(cwd, project_root_markers).await?; + let project_root = find_project_root(fs, cwd, project_root_markers).await?; let projects = project_trust_config.projects.unwrap_or_default(); let project_root_key = project_trust_key(project_root.as_path()); @@ -742,6 +754,7 @@ fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlV } async fn find_project_root( + fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, project_root_markers: &[String], ) -> io::Result { @@ -749,11 +762,15 @@ async fn find_project_root( return Ok(cwd.clone()); } - for ancestor in cwd.as_path().ancestors() { + for ancestor in cwd.ancestors() { for marker in project_root_markers { let marker_path = ancestor.join(marker); - if tokio::fs::metadata(&marker_path).await.is_ok() { - return AbsolutePathBuf::from_absolute_path(ancestor); + if fs + .get_metadata(&marker_path, /*sandbox*/ None) + .await + .is_ok() + { + return Ok(ancestor); } } } @@ -766,6 +783,7 @@ async fn find_project_root( /// 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( + fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, project_root: &AbsolutePathBuf, trust_context: &ProjectTrustContext, @@ -775,13 +793,12 @@ async fn load_project_layers( let codex_home_normalized = normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf()); let mut dirs = cwd - .as_path() .ancestors() .scan(false, |done, a| { if *done { None } else { - if a == project_root.as_path() { + if &a == project_root { *done = true; } Some(a) @@ -792,25 +809,24 @@ async fn load_project_layers( let mut layers = Vec::new(); for dir in dirs { - let dot_codex = dir.join(".codex"); - if !tokio::fs::metadata(&dot_codex) + let dot_codex_abs = dir.join(".codex"); + if !fs + .get_metadata(&dot_codex_abs, /*sandbox*/ None) .await - .map(|meta| meta.is_dir()) + .map(|metadata| metadata.is_directory) .unwrap_or(false) { continue; } - let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?; - let decision = trust_context.decision_for_dir(&layer_dir); - let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?; + let decision = trust_context.decision_for_dir(&dir); let dot_codex_normalized = normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf()); if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { continue; } let config_file = dot_codex_abs.join(CONFIG_TOML_FILE); - match tokio::fs::read_to_string(&config_file).await { + match fs.read_file_text(&config_file, /*sandbox*/ None).await { Ok(contents) => { let config: TomlValue = match toml::from_str(&contents) { Ok(config) => config, @@ -827,7 +843,7 @@ async fn load_project_layers( layers.push(project_layer_entry( trust_context, &dot_codex_abs, - &layer_dir, + &dir, TomlValue::Table(toml::map::Map::new()), /*config_toml_exists*/ true, )); @@ -839,7 +855,7 @@ async fn load_project_layers( let entry = project_layer_entry( trust_context, &dot_codex_abs, - &layer_dir, + &dir, config, /*config_toml_exists*/ true, ); @@ -853,7 +869,7 @@ async fn load_project_layers( layers.push(project_layer_entry( trust_context, &dot_codex_abs, - &layer_dir, + &dir, TomlValue::Table(toml::map::Map::new()), /*config_toml_exists*/ false, )); diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index ed69682e86de..c706e91062ff 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -16,6 +16,7 @@ use crate::config_loader::version_for_toml; use codex_config::CONFIG_TOML_FILE; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; +use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; @@ -92,6 +93,7 @@ async fn returns_config_error_for_invalid_user_config_toml() { let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let err = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(cwd), &[] as &[(String, TomlValue)], @@ -119,6 +121,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let err = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(cwd), &[] as &[(String, TomlValue)], @@ -203,6 +206,7 @@ extra = true let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let state = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(cwd), &[] as &[(String, TomlValue)], @@ -235,6 +239,7 @@ async fn returns_empty_when_all_layers_missing() { let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let layers = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(cwd), &[] as &[(String, TomlValue)], @@ -327,6 +332,7 @@ flag = false let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let state = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(cwd), &[] as &[(String, TomlValue)], @@ -428,6 +434,7 @@ allowed_sandbox_modes = ["read-only"] ); let state = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], @@ -489,6 +496,7 @@ allowed_approval_policies = ["never"] ); let state = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], @@ -529,8 +537,14 @@ personality = true ) .await?; + let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + load_requirements_toml( + LOCAL_FS.as_ref(), + &mut config_requirements_toml, + &requirements_file, + ) + .await?; assert_eq!( config_requirements_toml @@ -620,6 +634,7 @@ allowed_approval_policies = ["on-request"] ), ); let state = load_config_layers_state( + LOCAL_FS.as_ref(), tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], @@ -691,7 +706,12 @@ allowed_approval_policies = ["on-request"] guardian_policy_config: None, }, ); - load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + load_requirements_toml( + LOCAL_FS.as_ref(), + &mut config_requirements_toml, + &AbsolutePathBuf::try_from(requirements_file)?, + ) + .await?; assert_eq!( config_requirements_toml @@ -735,6 +755,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], @@ -771,6 +792,7 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; let err = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], @@ -823,6 +845,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { .await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], @@ -967,6 +990,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s .await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], @@ -1006,6 +1030,7 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?; let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], @@ -1062,6 +1087,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &project_dot_codex, Some(cwd), &[] as &[(String, TomlValue)], @@ -1132,6 +1158,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< .await?; let layers_untrusted = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home_untrusted, Some(cwd.clone()), &[] as &[(String, TomlValue)], @@ -1170,6 +1197,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< .await?; let layers_unknown = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home_unknown, Some(cwd), &[] as &[(String, TomlValue)], @@ -1328,6 +1356,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: } let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd.clone()), &[] as &[(String, TomlValue)], @@ -1390,6 +1419,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io )]; load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &cli_overrides, @@ -1432,6 +1462,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( + LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index b5eaf26b7bac..af4280bfb412 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -16,6 +16,7 @@ use codex_config::CONFIG_TOML_FILE; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::permissions_toml::overlay_network_domain_permissions; +use codex_exec_server::LOCAL_FS; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; use codex_network_proxy::NetworkProxyConfig; @@ -46,6 +47,7 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec Date: Thu, 16 Apr 2026 14:17:34 -0700 Subject: [PATCH 2/8] Route config loading through the filesystem abstraction --- codex-rs/Cargo.lock | 7 +- codex-rs/app-server-protocol/Cargo.toml | 1 - codex-rs/app-server-protocol/src/lib.rs | 2 +- .../app-server-protocol/src/protocol/v1.rs | 2 +- .../app-server/src/codex_message_processor.rs | 4 +- .../app-server/tests/suite/v2/thread_start.rs | 14 +- codex-rs/config/Cargo.toml | 3 +- codex-rs/config/src/config_toml.rs | 23 +-- codex-rs/core/src/agent/role.rs | 2 + codex-rs/core/src/config/agent_roles.rs | 179 ++++++++++-------- codex-rs/core/src/config/config_tests.rs | 32 +++- codex-rs/core/src/config/mod.rs | 77 +++++--- codex-rs/core/src/config_loader/mod.rs | 6 +- codex-rs/core/src/git_info_tests.rs | 50 +++-- codex-rs/core/src/guardian/tests.rs | 3 + codex-rs/core/src/realtime_context.rs | 39 ++-- codex-rs/git-utils/Cargo.toml | 2 + codex-rs/git-utils/src/info.rs | 46 +++-- codex-rs/git-utils/src/lib.rs | 74 +------- codex-rs/protocol/Cargo.toml | 1 - codex-rs/protocol/src/models.rs | 57 +++++- codex-rs/protocol/src/protocol.rs | 12 +- .../tui/src/onboarding/onboarding_screen.rs | 12 +- 23 files changed, 387 insertions(+), 261 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 296e51725c8a..08f677980130 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1531,7 +1531,6 @@ dependencies = [ "anyhow", "clap", "codex-experimental-api-macros", - "codex-git-utils", "codex-protocol", "codex-shell-command", "codex-utils-absolute-path", @@ -1857,12 +1856,11 @@ dependencies = [ "codex-app-server-protocol", "codex-execpolicy", "codex-features", - "codex-git-utils", "codex-model-provider-info", "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", - "dunce", + "codex-utils-path", "futures", "multimap", "pretty_assertions", @@ -2253,6 +2251,8 @@ name = "codex-git-utils" version = "0.0.0" dependencies = [ "assert_matches", + "codex-exec-server", + "codex-protocol", "codex-utils-absolute-path", "futures", "once_cell", @@ -2609,7 +2609,6 @@ dependencies = [ "chrono", "codex-async-utils", "codex-execpolicy", - "codex-git-utils", "codex-network-proxy", "codex-utils-absolute-path", "codex-utils-image", diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index d9ed5e87308e..0cb50d8549f5 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -15,7 +15,6 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-experimental-api-macros = { workspace = true } -codex-git-utils = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index d5c2f4b24324..46f0c9ae4154 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -4,7 +4,6 @@ mod jsonrpc_lite; mod protocol; mod schema_fixtures; -pub use codex_git_utils::GitSha; pub use experimental_api::*; pub use export::GenerateTsOptions; pub use export::generate_internal_json_schema; @@ -30,6 +29,7 @@ pub use protocol::v1::GetConversationSummaryParams; pub use protocol::v1::GetConversationSummaryResponse; pub use protocol::v1::GitDiffToRemoteParams; pub use protocol::v1::GitDiffToRemoteResponse; +pub use protocol::v1::GitSha; pub use protocol::v1::InitializeCapabilities; pub use protocol::v1::InitializeParams; pub use protocol::v1::InitializeResponse; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 6aa2e9fa30ca..d642e7fab954 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; @@ -11,6 +10,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FileChange; +pub use codex_protocol::protocol::GitSha; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 612ef2f6f07a..592157895d3a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2436,9 +2436,9 @@ impl CodexMessageProcessor { | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } )) { - let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path()) + let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) .await - .unwrap_or_else(|| config.cwd.to_path_buf()); + .unwrap_or_else(|| config.cwd.clone()); let cli_overrides_with_trust; let cli_overrides_for_reload = if let Err(err) = codex_core::config::set_project_trust_level( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index ab514252e4df..5853e56fbf44 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -20,11 +20,13 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; +use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -716,10 +718,11 @@ model_reasoning_effort = "high" assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let trusted_root = resolve_root_git_project_for_trust(workspace.path()) + let workspace_abs = AbsolutePathBuf::from_absolute_path(workspace.path())?; + let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &workspace_abs) .await - .unwrap_or_else(|| workspace.path().to_path_buf()); - assert!(config_toml.contains(&persisted_trust_path(&trusted_root))); + .unwrap_or(workspace_abs); + assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path()))); assert!(config_toml.contains("trust_level = \"trusted\"")); Ok(()) @@ -754,10 +757,11 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { .await??; let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let trusted_root = resolve_root_git_project_for_trust(&nested) + let nested_abs = AbsolutePathBuf::from_absolute_path(&nested)?; + let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested_abs) .await .expect("git root should resolve"); - assert!(config_toml.contains(&persisted_trust_path(&trusted_root))); + assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path()))); assert!(!config_toml.contains(&persisted_trust_path(&nested))); Ok(()) diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 9532d74d00d7..93ca283b867f 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -12,12 +12,11 @@ anyhow = { workspace = true } codex-app-server-protocol = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } -codex-git-utils = { workspace = true } codex-model-provider-info = { workspace = true } codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } -dunce = { workspace = true } +codex-utils-path = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } multimap = { workspace = true } schemars = { workspace = true } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 7de4e253e56a..83fe6c88e749 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -29,7 +29,6 @@ use crate::types::WindowsToml; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_features::FeaturesToml; -use codex_git_utils::resolve_root_git_project_for_trust; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; @@ -51,6 +50,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path::normalize_for_path_comparison; use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; @@ -601,7 +601,7 @@ impl ConfigToml { sandbox_mode_override: Option, profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, - resolved_cwd: &Path, + active_project: Option<&ProjectConfig>, sandbox_policy_constraint: Option<&crate::Constrained>, ) -> SandboxPolicy { let sandbox_mode_was_explicit = sandbox_mode_override.is_some() @@ -616,7 +616,7 @@ impl ConfigToml { // If no sandbox_mode is set but this directory has a trust decision, // default to workspace-write except on unsandboxed Windows where we // default to read-only. - self.get_active_project(resolved_cwd).await.and_then(|p| { + active_project.and_then(|p| { if p.is_trusted() || p.is_untrusted() { if cfg!(target_os = "windows") && windows_sandbox_level == WindowsSandboxLevel::Disabled @@ -677,9 +677,13 @@ impl ConfigToml { } /// Resolves the cwd to an existing project, or returns None if ConfigToml - /// does not contain a project corresponding to cwd or a git repo for cwd - pub async fn get_active_project(&self, resolved_cwd: &Path) -> Option { - let repo_root = resolve_root_git_project_for_trust(resolved_cwd).await; + /// does not contain a project corresponding to cwd or the resolved git repo + /// root for cwd. + pub fn get_active_project( + &self, + resolved_cwd: &Path, + repo_root: Option<&Path>, + ) -> Option { let projects = self.projects.clone().unwrap_or_default(); let resolved_cwd_key = project_trust_key(resolved_cwd); @@ -691,10 +695,7 @@ impl ConfigToml { return Some(project_config.clone()); } - // If cwd lives inside a git repo/worktree, check whether the root git project - // (the primary repository working directory) is trusted. This lets - // worktrees inherit trust from the main project. - if let Some(repo_root) = repo_root.as_deref() { + if let Some(repo_root) = repo_root { let repo_root_key = project_trust_key(repo_root); let repo_root_raw_key = repo_root.to_string_lossy().to_string(); if let Some(project_config_for_root) = projects @@ -734,7 +735,7 @@ impl ConfigToml { /// projects trust map. On Windows, strips UNC, when possible, to try to ensure /// that different paths that point to the same location have the same key. fn project_trust_key(project_path: &Path) -> String { - dunce::canonicalize(project_path) + normalize_for_path_comparison(project_path) .unwrap_or_else(|_| project_path.to_path_buf()) .to_string_lossy() .to_string() diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 61847f49baa9..9569c02d7149 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -18,6 +18,7 @@ use crate::config_loader::resolve_relative_paths_in_config_toml; use anyhow::anyhow; use codex_app_server_protocol::ConfigLayerSource; use codex_config::config_toml::ConfigToml; +use codex_exec_server::LOCAL_FS; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; @@ -168,6 +169,7 @@ mod reload { } let mut next_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), merged_config, reload_overrides(config, preserve_current_provider), config.codex_home.clone(), diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index 24d26ebf470e..8c8a068bb8d6 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -4,6 +4,7 @@ use crate::config_loader::ConfigLayerStackOrdering; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; use codex_config::config_toml::ConfigToml; +use codex_exec_server::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::Deserialize; @@ -14,7 +15,8 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; -pub(crate) fn load_agent_roles( +pub(crate) async fn load_agent_roles( + fs: &dyn ExecutorFileSystem, cfg: &ConfigToml, config_layer_stack: &ConfigLayerStack, startup_warnings: &mut Vec, @@ -24,7 +26,7 @@ pub(crate) fn load_agent_roles( /*include_disabled*/ false, ); if layers.is_empty() { - return load_agent_roles_without_layers(cfg); + return load_agent_roles_without_layers(fs, cfg).await; } let mut roles: BTreeMap = BTreeMap::new(); @@ -40,13 +42,14 @@ pub(crate) fn load_agent_roles( }; if let Some(agents_toml) = agents_toml { for (declared_role_name, role_toml) in &agents_toml.roles { - let (role_name, role) = match read_declared_role(declared_role_name, role_toml) { - Ok(role) => role, - Err(err) => { - push_agent_role_warning(startup_warnings, err); - continue; - } - }; + let (role_name, role) = + match read_declared_role(fs, declared_role_name, role_toml).await { + Ok(role) => role, + Err(err) => { + push_agent_role_warning(startup_warnings, err); + continue; + } + }; if let Some(config_file) = role.config_file.clone() { declared_role_files.insert(config_file); } @@ -68,10 +71,13 @@ pub(crate) fn load_agent_roles( if let Some(config_folder) = layer.config_folder() { for (role_name, role) in discover_agent_roles_in_dir( - config_folder.as_path().join("agents").as_path(), + fs, + &config_folder.join("agents"), &declared_role_files, startup_warnings, - )? { + ) + .await? + { if layer_roles.contains_key(&role_name) { push_agent_role_warning( startup_warnings, @@ -113,13 +119,14 @@ fn push_agent_role_warning(startup_warnings: &mut Vec, err: std::io::Err startup_warnings.push(message); } -fn load_agent_roles_without_layers( +async fn load_agent_roles_without_layers( + fs: &dyn ExecutorFileSystem, cfg: &ConfigToml, ) -> std::io::Result> { let mut roles = BTreeMap::new(); if let Some(agents_toml) = cfg.agents.as_ref() { for (declared_role_name, role_toml) in &agents_toml.roles { - let (role_name, role) = read_declared_role(declared_role_name, role_toml)?; + let (role_name, role) = read_declared_role(fs, declared_role_name, role_toml).await?; validate_required_agent_role_description(&role_name, role.description.as_deref())?; if roles.insert(role_name.clone(), role).is_some() { @@ -134,14 +141,17 @@ fn load_agent_roles_without_layers( Ok(roles) } -fn read_declared_role( +async fn read_declared_role( + fs: &dyn ExecutorFileSystem, declared_role_name: &str, role_toml: &AgentRoleToml, ) -> std::io::Result<(String, AgentRoleConfig)> { - let mut role = agent_role_config_from_toml(declared_role_name, role_toml)?; + let (mut role, config_file) = + agent_role_config_from_toml(fs, declared_role_name, role_toml).await?; let mut role_name = declared_role_name.to_string(); - if let Some(config_file) = role.config_file.as_deref() { - let parsed_file = read_resolved_agent_role_file(config_file, Some(declared_role_name))?; + if let Some(config_file) = config_file.as_ref() { + let parsed_file = + read_resolved_agent_role_file(fs, config_file, Some(declared_role_name)).await?; role_name = parsed_file.role_name; role.description = parsed_file.description.or(role.description); role.nickname_candidates = parsed_file.nickname_candidates.or(role.nickname_candidates); @@ -171,12 +181,17 @@ fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result std::io::Result { - let config_file = role.config_file.as_ref().map(AbsolutePathBuf::to_path_buf); - validate_agent_role_config_file(role_name, config_file.as_deref())?; +) -> std::io::Result<(AgentRoleConfig, Option)> { + let config_file = role + .config_file + .as_ref() + .map(AbsolutePathBuf::from_absolute_path) + .transpose()?; + validate_agent_role_config_file(fs, role_name, config_file.as_ref()).await?; let description = normalize_agent_role_description( &format!("agents.{role_name}.description"), role.description.as_deref(), @@ -186,11 +201,14 @@ fn agent_role_config_from_toml( role.nickname_candidates.as_deref(), )?; - Ok(AgentRoleConfig { - description, + Ok(( + AgentRoleConfig { + description, + config_file: config_file.as_ref().map(AbsolutePathBuf::to_path_buf), + nickname_candidates, + }, config_file, - nickname_candidates, - }) + )) } #[derive(Deserialize, Debug, Clone, Default, PartialEq)] @@ -293,15 +311,17 @@ pub(crate) fn parse_agent_role_file_contents( }) } -fn read_resolved_agent_role_file( - path: &Path, +async fn read_resolved_agent_role_file( + fs: &dyn ExecutorFileSystem, + path: &AbsolutePathBuf, role_name_hint: Option<&str>, ) -> std::io::Result { - let contents = std::fs::read_to_string(path)?; + let contents = fs.read_file_text(path, /*sandbox*/ None).await?; + let config_base_dir = path.parent().unwrap_or_else(|| path.clone()); parse_agent_role_file_contents( &contents, - path, - path.parent().unwrap_or(path), + path.as_path(), + config_base_dir.as_path(), role_name_hint, ) } @@ -359,31 +379,35 @@ fn validate_agent_role_file_developer_instructions( } } -fn validate_agent_role_config_file( +async fn validate_agent_role_config_file( + fs: &dyn ExecutorFileSystem, role_name: &str, - config_file: Option<&Path>, + config_file: Option<&AbsolutePathBuf>, ) -> std::io::Result<()> { let Some(config_file) = config_file else { return Ok(()); }; - let metadata = std::fs::metadata(config_file).map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "agents.{role_name}.config_file must point to an existing file at {}: {e}", - config_file.display() - ), - ) - })?; - if metadata.is_file() { + let metadata = fs + .get_metadata(config_file, /*sandbox*/ None) + .await + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "agents.{role_name}.config_file must point to an existing file at {}: {e}", + config_file.as_path().display() + ), + ) + })?; + if metadata.is_file { Ok(()) } else { Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "agents.{role_name}.config_file must point to a file: {}", - config_file.display() + config_file.as_path().display() ), )) } @@ -441,19 +465,20 @@ fn normalize_agent_role_nickname_candidates( Ok(Some(normalized_candidates)) } -fn discover_agent_roles_in_dir( - agents_dir: &Path, +async fn discover_agent_roles_in_dir( + fs: &dyn ExecutorFileSystem, + agents_dir: &AbsolutePathBuf, declared_role_files: &BTreeSet, startup_warnings: &mut Vec, ) -> std::io::Result> { let mut roles = BTreeMap::new(); - for agent_file in collect_agent_role_files(agents_dir)? { - if declared_role_files.contains(&agent_file) { + for agent_file in collect_agent_role_files(fs, agents_dir).await? { + if declared_role_files.contains(agent_file.as_path()) { continue; } let parsed_file = - match read_resolved_agent_role_file(&agent_file, /*role_name_hint*/ None) { + match read_resolved_agent_role_file(fs, &agent_file, /*role_name_hint*/ None).await { Ok(parsed_file) => parsed_file, Err(err) => { push_agent_role_warning(startup_warnings, err); @@ -468,7 +493,7 @@ fn discover_agent_roles_in_dir( std::io::ErrorKind::InvalidInput, format!( "duplicate agent role name `{role_name}` discovered in {}", - agents_dir.display() + agents_dir.as_path().display() ), ), ); @@ -478,7 +503,7 @@ fn discover_agent_roles_in_dir( role_name, AgentRoleConfig { description: parsed_file.description, - config_file: Some(agent_file), + config_file: Some(agent_file.to_path_buf()), nickname_candidates: parsed_file.nickname_candidates, }, ); @@ -487,36 +512,36 @@ fn discover_agent_roles_in_dir( Ok(roles) } -fn collect_agent_role_files(dir: &Path) -> std::io::Result> { +async fn collect_agent_role_files( + fs: &dyn ExecutorFileSystem, + dir: &AbsolutePathBuf, +) -> std::io::Result> { let mut files = Vec::new(); - collect_agent_role_files_recursive(dir, &mut files)?; - files.sort(); - Ok(files) -} - -fn collect_agent_role_files_recursive(dir: &Path, files: &mut Vec) -> std::io::Result<()> { - let read_dir = match std::fs::read_dir(dir) { - Ok(read_dir) => read_dir, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(err), - }; + let mut dirs = vec![dir.clone()]; + while let Some(dir) = dirs.pop() { + let entries = match fs.read_directory(&dir, /*sandbox*/ None).await { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; - for entry in read_dir { - let entry = entry?; - let path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_dir() { - collect_agent_role_files_recursive(&path, files)?; - continue; - } - if file_type.is_file() - && path - .extension() - .is_some_and(|extension| extension == "toml") - { - files.push(path); + for entry in entries { + let path = dir.join(entry.file_name); + if entry.is_directory { + dirs.push(path); + continue; + } + if entry.is_file + && path + .as_path() + .extension() + .is_some_and(|extension| extension == "toml") + { + files.push(path); + } } } - Ok(()) + files.sort(); + Ok(files) } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 5b178ed47626..0632a74249bf 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1175,7 +1175,7 @@ network_access = false # This should be ignored. sandbox_mode_override, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &PathBuf::from("/tmp/test"), + /*active_project*/ None, /*sandbox_policy_constraint*/ None, ) .await; @@ -1196,7 +1196,7 @@ network_access = true # This should be ignored. sandbox_mode_override, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &PathBuf::from("/tmp/test"), + /*active_project*/ None, /*sandbox_policy_constraint*/ None, ) .await; @@ -1213,6 +1213,9 @@ writable_roots = [ ] exclude_tmpdir_env_var = true exclude_slash_tmp = true + +[projects."/tmp/test"] +trust_level = "trusted" "#, serde_json::json!(writable_root) ); @@ -1225,7 +1228,7 @@ exclude_slash_tmp = true sandbox_mode_override, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &PathBuf::from("/tmp/test"), + /*active_project*/ None, /*sandbox_policy_constraint*/ None, ) .await; @@ -1254,9 +1257,6 @@ writable_roots = [ ] exclude_tmpdir_env_var = true exclude_slash_tmp = true - -[projects."/tmp/test"] -trust_level = "trusted" "#, serde_json::json!(writable_root) ); @@ -1269,7 +1269,7 @@ trust_level = "trusted" sandbox_mode_override, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &PathBuf::from("/tmp/test"), + /*active_project*/ None, /*sandbox_policy_constraint*/ None, ) .await; @@ -3469,6 +3469,7 @@ async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Resu .map_err(std::io::Error::other)?; let config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), @@ -3501,6 +3502,7 @@ async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std: .map_err(std::io::Error::other)?; let config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), @@ -5313,6 +5315,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() .expect("config layer stack"); let config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), fixture.cfg.clone(), ConfigOverrides { cwd: Some(fixture.cwd_path()), @@ -5520,13 +5523,16 @@ trust_level = "untrusted" let cfg = toml::from_str::(config_with_untrusted) .expect("TOML deserialization should succeed"); + let active_project = ProjectConfig { + trust_level: Some(TrustLevel::Untrusted), + }; let resolution = cfg .derive_sandbox_policy( /*sandbox_mode_override*/ None, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &PathBuf::from("/tmp/test"), + Some(&active_project), /*sandbox_policy_constraint*/ None, ) .await; @@ -5562,6 +5568,9 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau )])), ..Default::default() }; + let active_project = ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }; let constrained = Constrained::new(SandboxPolicy::DangerFullAccess, |candidate| { if matches!(candidate, SandboxPolicy::DangerFullAccess) { Ok(()) @@ -5580,7 +5589,7 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau /*sandbox_mode_override*/ None, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &project_path, + Some(&active_project), Some(&constrained), ) .await; @@ -5604,6 +5613,9 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb )])), ..Default::default() }; + let active_project = ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }; let constrained = Constrained::new(SandboxPolicy::new_workspace_write_policy(), |candidate| { if matches!(candidate, SandboxPolicy::WorkspaceWrite { .. }) { Ok(()) @@ -5622,7 +5634,7 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb /*sandbox_mode_override*/ None, /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, - &project_path, + Some(&active_project), Some(&constrained), ) .await; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c953dfecfb17..e2302f9dff8f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,6 +47,7 @@ use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_features::FeatureConfigSource; @@ -55,6 +56,7 @@ use codex_features::FeatureToml; use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; use codex_mcp::McpConfig; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; @@ -720,6 +722,7 @@ impl ConfigBuilder { } }; Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), config_toml, harness_overrides, codex_home, @@ -814,6 +817,7 @@ impl Config { let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)?; let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?; Self::load_config_with_layer_stack( + LOCAL_FS.as_ref(), config_toml, ConfigOverrides::default(), codex_home, @@ -881,8 +885,11 @@ pub(crate) fn deserialize_config_toml_with_base( .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } -fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result { - let file_contents = std::fs::read_to_string(path)?; +async fn load_catalog_json( + fs: &dyn ExecutorFileSystem, + path: &AbsolutePathBuf, +) -> std::io::Result { + let file_contents = fs.read_file_text(path, /*sandbox*/ None).await?; let catalog = serde_json::from_str::(&file_contents).map_err(|err| { std::io::Error::new( ErrorKind::InvalidData, @@ -904,12 +911,14 @@ fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result Ok(catalog) } -fn load_model_catalog( +async fn load_model_catalog( + fs: &dyn ExecutorFileSystem, model_catalog_json: Option, ) -> std::io::Result> { - model_catalog_json - .map(|path| load_catalog_json(&path)) - .transpose() + match model_catalog_json { + Some(path) => load_catalog_json(fs, &path).await.map(Some), + None => Ok(None), + } } fn filter_mcp_servers_by_requirements( @@ -1424,10 +1433,18 @@ impl Config { ) -> std::io::Result { // Note this ignores requirements.toml enforcement for tests. let config_layer_stack = ConfigLayerStack::default(); - Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack).await + Self::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + overrides, + codex_home, + config_layer_stack, + ) + .await } pub(crate) async fn load_config_with_layer_stack( + fs: &dyn ExecutorFileSystem, cfg: ConfigToml, overrides: ConfigOverrides, codex_home: AbsolutePathBuf, @@ -1549,9 +1566,12 @@ impl Config { .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); + let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg - .get_active_project(resolved_cwd.as_path()) - .await + .get_active_project( + resolved_cwd.as_path(), + repo_root.as_ref().map(AbsolutePathBuf::as_path), + ) .unwrap_or(ProjectConfig { trust_level: None }); let permission_config_syntax = resolve_permission_config_syntax( &config_layer_stack, @@ -1647,7 +1667,7 @@ impl Config { sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, - resolved_cwd.as_path(), + Some(&active_project), Some(&constrained_sandbox_policy), ) .await; @@ -1716,7 +1736,8 @@ impl Config { let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); let agent_roles = - agent_roles::load_agent_roles(&cfg, &config_layer_stack, &mut startup_warnings)?; + agent_roles::load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings) + .await?; let openai_base_url = cfg .openai_base_url @@ -1865,8 +1886,12 @@ impl Config { .model_instructions_file .as_ref() .or(cfg.model_instructions_file.as_ref()); - let file_base_instructions = - Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; + let file_base_instructions = Self::try_read_non_empty_file( + fs, + model_instructions_path, + "model instructions file", + ) + .await?; let base_instructions = base_instructions.or(file_base_instructions); let developer_instructions = developer_instructions.or(cfg.developer_instructions); let include_permissions_instructions = config_profile @@ -1897,9 +1922,11 @@ impl Config { .as_ref() .or(cfg.experimental_compact_prompt_file.as_ref()); let file_compact_prompt = Self::try_read_non_empty_file( + fs, experimental_compact_prompt_path, "experimental compact prompt file", - )?; + ) + .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); let js_repl_node_path = js_repl_node_path_override .or(config_profile.js_repl_node_path.map(Into::into)) @@ -1923,11 +1950,13 @@ impl Config { let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); let model_catalog = load_model_catalog( + fs, config_profile .model_catalog_json .clone() .or(cfg.model_catalog_json.clone()), - )?; + ) + .await?; let log_dir = cfg .log_dir @@ -2222,7 +2251,8 @@ impl Config { /// If `path` is `Some`, attempts to read the file at the given path and /// returns its contents as a trimmed `String`. If the file is empty, or /// is `Some` but cannot be read, returns an `Err`. - fn try_read_non_empty_file( + async fn try_read_non_empty_file( + fs: &dyn ExecutorFileSystem, path: Option<&AbsolutePathBuf>, context: &str, ) -> std::io::Result> { @@ -2230,12 +2260,15 @@ impl Config { return Ok(None); }; - let contents = std::fs::read_to_string(path).map_err(|e| { - std::io::Error::new( - e.kind(), - format!("failed to read {context} {}: {e}", path.display()), - ) - })?; + let contents = fs + .read_file_text(path, /*sandbox*/ None) + .await + .map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to read {context} {}: {e}", path.display()), + ) + })?; let s = contents.trim().to_string(); if s.is_empty() { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9f30595a89c0..fb02e670ef50 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -662,8 +662,10 @@ async fn project_trust_context( let projects = project_trust_config.projects.unwrap_or_default(); let project_root_key = project_trust_key(project_root.as_path()); - let repo_root = resolve_root_git_project_for_trust(cwd.as_path()).await; - let repo_root_key = repo_root.as_ref().map(|root| project_trust_key(root)); + let repo_root = resolve_root_git_project_for_trust(fs, cwd).await; + let repo_root_key = repo_root + .as_ref() + .map(|root| project_trust_key(root.as_path())); let projects_trust = projects .into_iter() diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index e3ef1fa62f79..ef1f0fac9621 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -1,3 +1,4 @@ +use codex_exec_server::LOCAL_FS; use codex_git_utils::GitInfo; use codex_git_utils::GitSha; use codex_git_utils::collect_git_info; @@ -5,6 +6,7 @@ use codex_git_utils::get_has_changes; use codex_git_utils::git_diff_to_remote; use codex_git_utils::recent_commits; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::skip_if_sandbox; use std::fs; use std::path::PathBuf; @@ -429,8 +431,9 @@ async fn test_get_git_working_tree_state_branch_fallback() { #[tokio::test] async fn resolve_root_git_project_for_trust_returns_none_outside_repo() { let tmp = TempDir::new().expect("tempdir"); + let tmp = AbsolutePathBuf::from_absolute_path(tmp.path()).unwrap(); assert!( - resolve_root_git_project_for_trust(tmp.path()) + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &tmp) .await .is_none() ); @@ -440,17 +443,17 @@ async fn resolve_root_git_project_for_trust_returns_none_outside_repo() { async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let repo_path = create_test_git_repo(&temp_dir).await; - let expected = std::fs::canonicalize(&repo_path).unwrap(); + let repo_path = AbsolutePathBuf::from_absolute_path(&repo_path).unwrap(); assert_eq!( - resolve_root_git_project_for_trust(&repo_path).await, - Some(expected.clone()) + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &repo_path).await, + Some(repo_path.clone()) ); let nested = repo_path.join("sub/dir"); - std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(nested.as_path()).unwrap(); assert_eq!( - resolve_root_git_project_for_trust(&nested).await, - Some(expected) + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await, + Some(repo_path) ); } @@ -473,16 +476,15 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro .output() .expect("git worktree add"); - let expected = std::fs::canonicalize(&repo_path).ok(); - let got = resolve_root_git_project_for_trust(&wt_root) - .await - .and_then(|p| std::fs::canonicalize(p).ok()); + let expected = Some( + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(&repo_path).unwrap()).unwrap(), + ); + let wt_root = AbsolutePathBuf::from_absolute_path(&wt_root).unwrap(); + let got = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &wt_root).await; assert_eq!(got, expected); let nested = wt_root.join("nested/sub"); - std::fs::create_dir_all(&nested).unwrap(); - let got_nested = resolve_root_git_project_for_trust(&nested) - .await - .and_then(|p| std::fs::canonicalize(p).ok()); + std::fs::create_dir_all(nested.as_path()).unwrap(); + let got_nested = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await; assert_eq!(got_nested, expected); } @@ -502,13 +504,15 @@ async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git ) .unwrap(); - let expected = std::fs::canonicalize(&repo_root).unwrap(); + let expected = AbsolutePathBuf::from_absolute_path(&repo_root).unwrap(); + let worktree_root = AbsolutePathBuf::from_absolute_path(&worktree_root).unwrap(); assert_eq!( - resolve_root_git_project_for_trust(&worktree_root).await, + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root).await, Some(expected.clone()) ); + let nested = worktree_root.join("nested"); assert_eq!( - resolve_root_git_project_for_trust(&worktree_root.join("nested")).await, + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await, Some(expected) ); } @@ -529,9 +533,15 @@ async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() ) .unwrap(); - assert!(resolve_root_git_project_for_trust(&proj).await.is_none()); + let proj = AbsolutePathBuf::from_absolute_path(&proj).unwrap(); + assert!( + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &proj) + .await + .is_none() + ); + let nested = proj.join("nested"); assert!( - resolve_root_git_project_for_trust(&proj.join("nested")) + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested) .await .is_none() ); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 485f22255199..2712a9403932 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -16,6 +16,7 @@ use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::test_support; use codex_config::config_toml::ConfigToml; +use codex_exec_server::LOCAL_FS; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::ThreadId; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -1740,6 +1741,7 @@ async fn guardian_review_session_config_uses_requirements_guardian_policy_config ) .expect("config layer stack"); let parent_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(workspace.path().to_path_buf()), @@ -1776,6 +1778,7 @@ async fn guardian_review_session_config_uses_default_guardian_policy_without_req ConfigLayerStack::new(Vec::new(), Default::default(), Default::default()) .expect("config layer stack"); let parent_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), ConfigToml::default(), ConfigOverrides { cwd: Some(workspace.path().to_path_buf()), diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index fc17dab99bb8..550748eda381 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -2,12 +2,14 @@ use crate::codex::Session; use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; use chrono::Utc; +use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::models::ResponseItem; use codex_thread_store::ListThreadsParams; use codex_thread_store::StoredThread; use codex_thread_store::ThreadSortKey; use codex_thread_store::ThreadStore; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; use dirs::home_dir; @@ -147,15 +149,23 @@ async fn load_recent_threads(sess: &Session) -> Vec { async fn build_recent_work_section(cwd: &Path, recent_threads: &[StoredThread]) -> Option { let mut groups: HashMap> = HashMap::new(); for entry in recent_threads { - let group = resolve_root_git_project_for_trust(&entry.cwd) - .await - .unwrap_or_else(|| entry.cwd.clone()); + let group = match AbsolutePathBuf::from_absolute_path(&entry.cwd) { + Ok(cwd) => resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &cwd) + .await + .map(AbsolutePathBuf::into_path_buf) + .unwrap_or_else(|| entry.cwd.clone()), + Err(_) => entry.cwd.clone(), + }; groups.entry(group).or_default().push(entry); } - let current_group = resolve_root_git_project_for_trust(cwd) - .await - .unwrap_or_else(|| cwd.to_path_buf()); + let current_group = match AbsolutePathBuf::from_absolute_path(cwd) { + Ok(cwd_abs) => resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &cwd_abs) + .await + .map(AbsolutePathBuf::into_path_buf) + .unwrap_or_else(|| cwd.to_path_buf()), + Err(_) => cwd.to_path_buf(), + }; let mut groups = groups.into_iter().collect::>(); groups.sort_by(|(left_group, left_entries), (right_group, right_entries)| { let left_latest = left_entries @@ -311,7 +321,12 @@ async fn build_workspace_section_with_user_root( cwd: &Path, user_root: Option, ) -> Option { - let git_root = resolve_root_git_project_for_trust(cwd).await; + let git_root = match AbsolutePathBuf::from_absolute_path(cwd) { + Ok(cwd) => resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &cwd) + .await + .map(AbsolutePathBuf::into_path_buf), + Err(_) => None, + }; let cwd_tree = render_tree(cwd); let git_root_tree = git_root .as_ref() @@ -473,10 +488,12 @@ async fn format_thread_group( entries: Vec<&StoredThread>, ) -> Option { let latest = entries.first()?; - let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path()) - .await - .is_some() - { + let group_label = if match AbsolutePathBuf::from_absolute_path(&latest.cwd) { + Ok(cwd) => resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &cwd) + .await + .is_some(), + Err(_) => false, + } { format!("### Git repo: {}", group.display()) } else { format!("### Directory: {}", group.display()) diff --git a/codex-rs/git-utils/Cargo.toml b/codex-rs/git-utils/Cargo.toml index 7ecc72dfa9ca..5500546db7c6 100644 --- a/codex-rs/git-utils/Cargo.toml +++ b/codex-rs/git-utils/Cargo.toml @@ -9,6 +9,8 @@ readme = "README.md" workspace = true [dependencies] +codex-exec-server = { workspace = true } +codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } futures = { workspace = true, features = ["alloc"] } once_cell = { workspace = true } diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index e28a0bae42ff..e7642a8658eb 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -4,6 +4,7 @@ use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; +use codex_exec_server::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::join_all; use schemars::JsonSchema; @@ -618,30 +619,38 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { /// `[get_git_repo_root]`, but resolves to the root of the main /// repository. Handles worktrees via filesystem inspection without invoking /// the `git` executable. -pub async fn resolve_root_git_project_for_trust(cwd: &Path) -> Option { - let base = if cwd.is_dir() { cwd } else { cwd.parent()? }; - let (repo_root, dot_git) = find_ancestor_git_entry(base)?; - if dot_git.is_dir() { - return Some(canonicalize_or_raw(repo_root)); +pub async fn resolve_root_git_project_for_trust( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, +) -> Option { + let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => cwd.clone(), + _ => cwd.parent()?, + }; + let (repo_root, dot_git) = find_ancestor_git_entry_with_fs(fs, &base).await?; + if fs + .get_metadata(&dot_git, /*sandbox*/ None) + .await + .ok()? + .is_directory + { + return Some(repo_root); } - let git_dir_s = std::fs::read_to_string(&dot_git).ok()?; + let git_dir_s = fs.read_file_text(&dot_git, /*sandbox*/ None).await.ok()?; let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim(); if git_dir_rel.is_empty() { return None; } - let git_dir_path = canonicalize_or_raw( - AbsolutePathBuf::resolve_path_against_base(git_dir_rel, &repo_root).into_path_buf(), - ); + let git_dir_path = AbsolutePathBuf::resolve_path_against_base(git_dir_rel, repo_root.as_path()); let worktrees_dir = git_dir_path.parent()?; - if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) { + if worktrees_dir.as_path().file_name() != Some(OsStr::new("worktrees")) { return None; } let common_dir = worktrees_dir.parent()?; - let main_repo_root = common_dir.parent()?; - Some(canonicalize_or_raw(main_repo_root.to_path_buf())) + common_dir.parent() } fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { @@ -663,8 +672,17 @@ fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { None } -fn canonicalize_or_raw(path: PathBuf) -> PathBuf { - std::fs::canonicalize(&path).unwrap_or(path) +async fn find_ancestor_git_entry_with_fs( + fs: &dyn ExecutorFileSystem, + base_dir: &AbsolutePathBuf, +) -> Option<(AbsolutePathBuf, AbsolutePathBuf)> { + for dir in base_dir.ancestors() { + let dot_git = dir.join(".git"); + if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() { + return Some((dir, dot_git)); + } + } + None } /// Returns a list of local git branches. diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index 8f188e4bf0f8..d9d61d86504a 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -1,6 +1,3 @@ -use std::fmt; -use std::path::PathBuf; - mod apply; mod branch; mod errors; @@ -16,6 +13,8 @@ pub use apply::extract_paths_from_patch; pub use apply::parse_git_apply_output; pub use apply::stage_paths; pub use branch::merge_base_with_head; +pub use codex_protocol::models::GhostCommit; +pub use codex_protocol::protocol::GitSha; pub use errors::GitToolingError; pub use ghost_commits::CreateGhostCommitOptions; pub use ghost_commits::GhostSnapshotConfig; @@ -45,72 +44,3 @@ pub use info::local_git_branches; pub use info::recent_commits; pub use info::resolve_root_git_project_for_trust; pub use platform::create_symlink; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use ts_rs::TS; - -type CommitID = String; - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] -#[serde(transparent)] -#[ts(type = "string")] -pub struct GitSha(pub String); - -impl GitSha { - pub fn new(sha: &str) -> Self { - Self(sha.to_string()) - } -} - -/// Details of a ghost commit created from a repository state. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -pub struct GhostCommit { - id: CommitID, - parent: Option, - preexisting_untracked_files: Vec, - preexisting_untracked_dirs: Vec, -} - -impl GhostCommit { - /// Create a new ghost commit wrapper from a raw commit ID and optional parent. - pub fn new( - id: CommitID, - parent: Option, - preexisting_untracked_files: Vec, - preexisting_untracked_dirs: Vec, - ) -> Self { - Self { - id, - parent, - preexisting_untracked_files, - preexisting_untracked_dirs, - } - } - - /// Commit ID for the snapshot. - pub fn id(&self) -> &str { - &self.id - } - - /// Parent commit ID, if the repository had a `HEAD` at creation time. - pub fn parent(&self) -> Option<&str> { - self.parent.as_deref() - } - - /// Untracked or ignored files that already existed when the snapshot was captured. - pub fn preexisting_untracked_files(&self) -> &[PathBuf] { - &self.preexisting_untracked_files - } - - /// Untracked or ignored directories that already existed when the snapshot was captured. - pub fn preexisting_untracked_dirs(&self) -> &[PathBuf] { - &self.preexisting_untracked_dirs - } -} - -impl fmt::Display for GhostCommit { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.id) - } -} diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index f70dc9458519..2bd46d7d5aed 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -16,7 +16,6 @@ chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-async-utils = { workspace = true } codex-execpolicy = { workspace = true } -codex-git-utils = { workspace = true } codex-network-proxy = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-image = { workspace = true } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 220045652725..8c55d056cd7f 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use std::fmt; use std::path::Path; +use std::path::PathBuf; use std::sync::LazyLock; use codex_utils_image::PromptImageMode; @@ -25,7 +27,6 @@ use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; use crate::user_input::UserInput; use codex_execpolicy::Policy; -use codex_git_utils::GhostCommit; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_image::ImageProcessingError; use schemars::JsonSchema; @@ -45,6 +46,60 @@ static SANDBOX_MODE_READ_ONLY_TEMPLATE: LazyLock