diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e7ff377e57..1ded6ec62a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -710,10 +710,18 @@ impl Session { let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. if config.features.enabled(Feature::ShellSnapshot) { + let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); default_shell.shell_snapshot = ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell) .await .map(Arc::new); + let success = if default_shell.shell_snapshot.is_some() { + "true" + } else { + "false" + }; + let _ = timer.map(|timer| timer.record(&[("success", success)])); + otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]) } let state = SessionState::new(session_configuration.clone()); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 970ec7c4df..b4e6c0c8bf 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -531,7 +531,7 @@ pub async fn load_config_as_toml_with_cli_overrides( Ok(cfg) } -fn deserialize_config_toml_with_base( +pub(crate) fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, ) -> std::io::Result { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9a8791eeef..0460e01572 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -12,10 +12,13 @@ mod tests; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigToml; +use crate::config::deserialize_config_toml_with_base; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::layer_io::LoadedConfigLayers; +use crate::git_info::resolve_root_git_project_for_trust; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -64,9 +67,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` /// - user `${CODEX_HOME}/config.toml` -/// - cwd `${PWD}/config.toml` -/// - tree parent directories up to root looking for `./.codex/config.toml` -/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` +/// - cwd `${PWD}/config.toml` (only when the directory is trusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. @@ -114,6 +117,12 @@ pub async fn load_config_layers_state( let mut layers = Vec::::new(); + let cli_overrides_layer = if cli_overrides.is_empty() { + None + } else { + Some(overrides::build_cli_overrides_layer(cli_overrides)) + }; + // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = if cfg!(unix) { @@ -158,17 +167,22 @@ pub async fn load_config_layers_state( for layer in &layers { merge_toml_values(&mut merged_so_far, &layer.config); } + if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { + merge_toml_values(&mut merged_so_far, cli_overrides_layer); + } + let project_root_markers = project_root_markers_from_config(&merged_so_far)? .unwrap_or_else(default_project_root_markers); - - let project_root = find_project_root(&cwd, &project_root_markers).await?; - let project_layers = load_project_layers(&cwd, &project_root).await?; - layers.extend(project_layers); + if let Some(project_root) = + trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).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() { - let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides); + if let Some(cli_overrides_layer) = cli_overrides_layer { layers.push(ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, cli_overrides_layer, @@ -388,6 +402,44 @@ fn default_project_root_markers() -> Vec { .collect() } +async fn trusted_project_root( + merged_config: &TomlValue, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], + config_base_dir: &Path, +) -> io::Result> { + let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?; + + let project_root = find_project_root(cwd, project_root_markers).await?; + let projects = config_toml.projects.unwrap_or_default(); + + let cwd_key = cwd.as_path().to_string_lossy().to_string(); + let project_root_key = project_root.as_path().to_string_lossy().to_string(); + let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path()) + .map(|root| root.to_string_lossy().to_string()); + + let trust_level = projects + .get(&cwd_key) + .and_then(|project| project.trust_level) + .or_else(|| { + projects + .get(&project_root_key) + .and_then(|project| project.trust_level) + }) + .or_else(|| { + repo_root_key + .as_ref() + .and_then(|root| projects.get(root)) + .and_then(|project| project.trust_level) + }); + + if matches!(trust_level, Some(TrustLevel::Trusted)) { + Ok(Some(project_root)) + } else { + Ok(None) + } +} + /// 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. diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 3738f95b22..773d9951d1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -3,19 +3,47 @@ use super::load_config_layers_state; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; +use crate::config::ConfigToml; +use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigRequirements; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; #[cfg(target_os = "macos")] use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; +async fn make_config_for_test( + codex_home: &Path, + project_path: &Path, + trust_level: TrustLevel, + project_root_markers: Option>, +) -> std::io::Result<()> { + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + project_path.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(trust_level), + }, + )])), + project_root_markers, + ..Default::default() + }) + .expect("serialize config"), + ) + .await +} + #[tokio::test] async fn merges_managed_config_layer_on_top() { let tmp = tempdir().expect("tempdir"); @@ -365,6 +393,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -429,6 +458,7 @@ experimental_instructions_file = "child.txt" let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let config = ConfigBuilder::default() .codex_home(codex_home) @@ -458,6 +488,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -486,6 +517,95 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Ok(()) } +#[tokio::test] +async fn project_layers_skipped_when_untrusted_or_unknown() -> 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::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + + let codex_home_untrusted = tmp.path().join("home_untrusted"); + tokio::fs::create_dir_all(&codex_home_untrusted).await?; + make_config_for_test( + &codex_home_untrusted, + &project_root, + TrustLevel::Untrusted, + None, + ) + .await?; + + let layers_untrusted = load_config_layers_state( + &codex_home_untrusted, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_untrusted = layers_untrusted + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_untrusted, 0); + assert_eq!(layers_untrusted.effective_config().get("foo"), None); + + let codex_home_unknown = tmp.path().join("home_unknown"); + tokio::fs::create_dir_all(&codex_home_unknown).await?; + + let layers_unknown = load_config_layers_state( + &codex_home_unknown, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_unknown = layers_unknown + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_unknown, 0); + assert_eq!(layers_unknown.effective_config().get("foo"), None); + + Ok(()) +} + +#[tokio::test] +async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> 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::write(project_root.join(".git"), "gitdir: here").await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cli_overrides = vec![( + "experimental_instructions_file".to_string(), + TomlValue::String("relative.md".to_string()), + )]; + + load_config_layers_state( + &codex_home, + Some(cwd), + &cli_overrides, + LoaderOverrides::default(), + ) + .await?; + + Ok(()) +} + #[tokio::test] async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()> { let tmp = tempdir()?; @@ -507,11 +627,11 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#" -project_root_markers = [".hg"] -"#, + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + Some(vec![".hg".to_string()]), ) .await?; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 82fea6e71f..b7cc66159a 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -1,3 +1,16 @@ +use super::cache::ModelsCacheManager; +use crate::api_bridge::auth_provider_from_auth; +use crate::api_bridge::map_api_error; +use crate::auth::AuthManager; +use crate::config::Config; +use crate::default_client::build_reqwest_client; +use crate::error::CodexErr; +use crate::error::Result as CoreResult; +use crate::features::Feature; +use crate::model_provider_info::ModelProviderInfo; +use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use crate::models_manager::model_info; +use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; @@ -14,20 +27,6 @@ use tokio::sync::TryLockError; use tokio::time::timeout; use tracing::error; -use super::cache::ModelsCacheManager; -use crate::api_bridge::auth_provider_from_auth; -use crate::api_bridge::map_api_error; -use crate::auth::AuthManager; -use crate::config::Config; -use crate::default_client::build_reqwest_client; -use crate::error::CodexErr; -use crate::error::Result as CoreResult; -use crate::features::Feature; -use crate::model_provider_info::ModelProviderInfo; -use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; -use crate::models_manager::model_info; -use crate::models_manager::model_presets::builtin_model_presets; - const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); @@ -211,6 +210,8 @@ impl ModelsManager { } async fn fetch_and_update_models(&self) -> CoreResult<()> { + let _timer = + codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; @@ -249,6 +250,8 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + let _timer = + codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]); let cache = match self.cache_manager.load_fresh().await { Some(cache) => cache, None => return false, diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index f6dd3dae92..1e02926572 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -550,15 +550,20 @@ fn extract_frontmatter(contents: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; + use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use toml::Value as TomlValue; @@ -570,6 +575,21 @@ mod tests { } async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + cwd.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }) + .expect("serialize config"), + ) + .unwrap(); + let harness_overrides = ConfigOverrides { cwd: Some(cwd), ..Default::default() diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index fa7cc93bb7..32540d1071 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -170,3 +170,11 @@ impl OtelManager { Ok(()) } } + +/// Start a metrics timer using the globally installed metrics client. +pub fn start_global_timer(name: &str, tags: &[(&str, &str)]) -> MetricsResult { + let Some(metrics) = crate::metrics::global() else { + return Err(MetricsError::ExporterDisabled); + }; + metrics.start_timer(name, tags) +} diff --git a/codex-rs/otel/src/metrics/timer.rs b/codex-rs/otel/src/metrics/timer.rs index 6c85f413b3..86712f6a0e 100644 --- a/codex-rs/otel/src/metrics/timer.rs +++ b/codex-rs/otel/src/metrics/timer.rs @@ -12,7 +12,7 @@ pub struct Timer { impl Drop for Timer { fn drop(&mut self) { - if let Err(e) = self.record() { + if let Err(e) = self.record(&[]) { tracing::error!("metrics client error: {}", e); } } @@ -31,12 +31,10 @@ impl Timer { } } - pub fn record(&self) -> Result<()> { - let tags = self - .tags - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect::>(); + pub fn record(&self, additional_tags: &[(&str, &str)]) -> Result<()> { + let mut tags = Vec::with_capacity(self.tags.len() + additional_tags.len()); + tags.extend(additional_tags); + tags.extend(self.tags.iter().map(|(k, v)| (k.as_str(), v.as_str()))); self.client .record_duration(&self.name, self.start_time.elapsed(), &tags) }