diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ff1dddf2f..fc9ebf746 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2215,14 +2215,28 @@ impl CodexMessageProcessor { } }; + // The user may have requested WorkspaceWrite or DangerFullAccess via + // the command line, though in the process of deriving the Config, it + // could be downgraded to ReadOnly (perhaps there is no sandbox + // available on Windows or the enterprise config disallows it). The cwd + // should still be considered "trusted" in this case. + let requested_sandbox_trusts_project = matches!( + typesafe_overrides.sandbox_mode, + Some( + codex_protocol::config_types::SandboxMode::WorkspaceWrite + | codex_protocol::config_types::SandboxMode::DangerFullAccess + ) + ); + if requested_cwd.is_some() && !config.active_project.is_trusted() - && matches!( - config.permissions.sandbox_policy.get(), - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_protocol::protocol::SandboxPolicy::DangerFullAccess - | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } - ) + && (requested_sandbox_trusts_project + || matches!( + config.permissions.sandbox_policy.get(), + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::DangerFullAccess + | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + )) { let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path()) .unwrap_or_else(|| config.cwd.to_path_buf()); diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index d5e690327..f44beb9a2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -95,7 +95,10 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> assert_eq!(status, &CommandExecutionStatus::InProgress); let delta = wait_for_command_execution_output_delta(&mut mcp, &command_id).await?; - assert_eq!(delta.delta, expected_output); + assert_eq!( + delta.delta.trim_end_matches(['\r', '\n']), + expected_output.trim_end_matches(['\r', '\n']) + ); let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; let ThreadItem::CommandExecution { 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 3b116f8ed..409e05404 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -644,7 +644,7 @@ model_reasoning_effort = "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()) .unwrap_or_else(|| workspace.path().to_path_buf()); - assert!(config_toml.contains(&trusted_root.display().to_string())); + assert!(config_toml.contains(&persisted_trust_path(&trusted_root))); assert!(config_toml.contains("trust_level = \"trusted\"")); Ok(()) @@ -681,8 +681,8 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; let trusted_root = resolve_root_git_project_for_trust(&nested).expect("git root should resolve"); - assert!(config_toml.contains(&trusted_root.display().to_string())); - assert!(!config_toml.contains(&nested.display().to_string())); + assert!(config_toml.contains(&persisted_trust_path(&trusted_root))); + assert!(!config_toml.contains(&persisted_trust_path(&nested))); Ok(()) } @@ -776,6 +776,21 @@ fn create_config_toml_without_approval_policy( ) } +fn persisted_trust_path(project_path: &Path) -> String { + let project_path = + std::fs::canonicalize(project_path).unwrap_or_else(|_| project_path.to_path_buf()); + let project_path = project_path.display().to_string(); + + if let Some(project_path) = project_path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{project_path}"); + } + + project_path + .strip_prefix(r"\\?\") + .unwrap_or(&project_path) + .to_string() +} + fn create_config_toml_with_optional_approval_policy( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8f1db5ed7..a37d451da 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -12,6 +12,7 @@ use crate::config_loader::McpServerRequirement; use crate::config_loader::ResidencyRequirement; use crate::config_loader::Sourced; use crate::config_loader::load_config_layers_state; +use crate::config_loader::project_trust_key; use crate::memories::memory_root; use crate::path_utils::normalize_for_native_workdir; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; @@ -92,7 +93,6 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; -use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; @@ -1017,7 +1017,7 @@ pub(crate) fn set_project_trust_level_inner( // // [projects] // "/path/to/project" = { trust_level = "trusted" } - let project_key = project_path.to_string_lossy().to_string(); + let project_key = project_trust_key(project_path); // Ensure top-level `projects` exists as a non-inline, explicit table. If it // exists but was previously represented as a non-table (e.g., inline), @@ -1745,18 +1745,27 @@ impl ConfigToml { pub fn get_active_project(&self, resolved_cwd: &Path) -> Option { let projects = self.projects.clone().unwrap_or_default(); - if let Some(project_config) = projects.get(&resolved_cwd.to_string_lossy().to_string()) { + let resolved_cwd_key = project_trust_key(resolved_cwd); + let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string(); + if let Some(project_config) = projects + .get(&resolved_cwd_key) + .or_else(|| projects.get(&resolved_cwd_raw_key)) + { 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) = resolve_root_git_project_for_trust(resolved_cwd) - && let Some(project_config_for_root) = - projects.get(&repo_root.to_string_lossy().to_string_lossy().to_string()) - { - return Some(project_config_for_root.clone()); + if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) { + let repo_root_key = project_trust_key(repo_root.as_path()); + let repo_root_raw_key = repo_root.to_string_lossy().to_string(); + if let Some(project_config_for_root) = projects + .get(&repo_root_key) + .or_else(|| projects.get(&repo_root_raw_key)) + { + return Some(project_config_for_root.clone()); + } } None diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index df3366595..a00512967 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -559,7 +559,7 @@ impl ProjectTrustDecision { impl ProjectTrustContext { fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { - let dir_key = dir.as_path().to_string_lossy().to_string(); + let dir_key = project_trust_key(dir.as_path()); if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() { return ProjectTrustDecision { trust_level: Some(trust_level), @@ -647,15 +647,17 @@ async fn project_trust_context( let project_root = find_project_root(cwd, project_root_markers).await?; let projects = project_trust_config.projects.unwrap_or_default(); - let project_root_key = project_root.as_path().to_string_lossy().to_string(); + let project_root_key = project_trust_key(project_root.as_path()); let repo_root = resolve_root_git_project_for_trust(cwd.as_path()); - let repo_root_key = repo_root - .as_ref() - .map(|root| root.to_string_lossy().to_string()); + let repo_root_key = repo_root.as_ref().map(|root| project_trust_key(root)); let projects_trust = projects .into_iter() - .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) + .filter_map(|(key, project)| { + project + .trust_level + .map(|trust_level| (project_trust_key(Path::new(&key)), trust_level)) + }) .collect(); Ok(ProjectTrustContext { @@ -667,6 +669,16 @@ async fn project_trust_context( }) } +/// Canonicalize the path and convert it to a string to be used as a key in the +/// 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. +pub fn project_trust_key(project_path: &Path) -> String { + normalize_path(project_path) + .unwrap_or_else(|_| project_path.to_path_buf()) + .to_string_lossy() + .to_string() +} + /// 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.