From 19fe0f3077eb9ac468eb91ee585b2727cdc36df5 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Fri, 20 Mar 2026 13:44:30 -0700 Subject: [PATCH 01/14] config: support tilde paths in MDM config --- codex-rs/core/src/config/mod.rs | 17 ++-- codex-rs/core/src/config/service.rs | 6 +- codex-rs/core/src/config/service_tests.rs | 50 ++++++++++++ codex-rs/core/src/config_loader/tests.rs | 48 +++++++++++ codex-rs/utils/absolute-path/src/lib.rs | 99 ++++++++++++++++++++--- 5 files changed, 200 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cbbd02390552..0ab0bd2a2643 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -697,11 +697,10 @@ impl ConfigBuilder { .await?; let merged_toml = config_layer_stack.effective_config(); - // Note that each layer in ConfigLayerStack should have resolved - // relative paths to absolute paths based on the parent folder of the - // respective config file, so we should be safe to deserialize without - // AbsolutePathBufGuard here. - let config_toml: ConfigToml = match merged_toml.try_into() { + // Config layers loaded from MDM can still contain `~/...` paths because + // there is no config-file directory to resolve them against. Allow home + // expansion here while still rejecting relative paths such as `./...`. + let config_toml = match deserialize_merged_config_toml(merged_toml, &codex_home) { Ok(config_toml) => config_toml, Err(err) => { if let Some(config_error) = @@ -816,6 +815,14 @@ pub(crate) fn deserialize_config_toml_with_base( .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } +pub(crate) fn deserialize_merged_config_toml( + root_value: TomlValue, + config_base_dir: &Path, +) -> Result { + let _guard = AbsolutePathBufGuard::home_expansion_only(config_base_dir); + root_value.try_into() +} + fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result { let file_contents = std::fs::read_to_string(path)?; let catalog = serde_json::from_str::(&file_contents).map_err(|err| { diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 279af666c1e8..58a2b38c8e61 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -169,9 +169,9 @@ impl ConfigService { let effective = layers.effective_config(); - let effective_config_toml: ConfigToml = effective - .try_into() - .map_err(|err| ConfigServiceError::toml("invalid configuration", err))?; + let effective_config_toml = + crate::config::deserialize_merged_config_toml(effective, &self.codex_home) + .map_err(|err| ConfigServiceError::toml("invalid configuration", err))?; let json_value = serde_json::to_value(&effective_config_toml) .map_err(|err| ConfigServiceError::json("failed to serialize configuration", err))?; diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index bc3006a9a9c5..01dfd9f641df 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -237,6 +237,56 @@ async fn read_includes_origins_and_layers() { )); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn read_expands_home_directory_paths_from_managed_preferences() -> Result<()> { + use base64::Engine; + + let Some(home) = dirs::home_dir() else { + return Ok(()); + }; + let tmp = tempdir().expect("tempdir"); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let response = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read succeeds"); + + assert_eq!( + response + .config + .sandbox_workspace_write + .expect("workspace-write settings") + .writable_roots, + vec![home.join("code")] + ); + + Ok(()) +} + #[tokio::test] async fn write_value_reports_override() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 03be02ebfe09..6a2bc0b6b991 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -380,6 +380,54 @@ flag = false assert!(raw.contains("value = \"managed\"")); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()> +{ + use base64::Engine; + + let Some(home) = dirs::home_dir() else { + return Ok(()); + }; + let tmp = tempdir()?; + + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }) + .build() + .await?; + + let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; + match config.permissions.sandbox_policy.get() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_root) + .count(), + 1, + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> { diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 1ba1c40cfb2a..af09dcb2bfe1 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -142,20 +142,66 @@ impl TryFrom for AbsolutePathBuf { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AbsolutePathBufGuardMode { + ResolveRelativePaths, + ExpandHomeOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AbsolutePathBufGuardState { + base_path: PathBuf, + mode: AbsolutePathBufGuardMode, +} + +impl AbsolutePathBufGuardState { + fn deserialize_absolute_path(&self, path: PathBuf) -> std::io::Result { + match self.mode { + AbsolutePathBufGuardMode::ResolveRelativePaths => { + AbsolutePathBuf::resolve_path_against_base(path, &self.base_path) + } + AbsolutePathBufGuardMode::ExpandHomeOnly => { + let expanded = AbsolutePathBuf::maybe_expand_home_directory(&path); + if expanded.is_absolute() { + AbsolutePathBuf::from_absolute_path(expanded) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "AbsolutePathBuf deserialized without an absolute path", + )) + } + } + } + } +} + thread_local! { - static ABSOLUTE_PATH_BASE: RefCell> = const { RefCell::new(None) }; + static ABSOLUTE_PATH_GUARD_STATE: RefCell> = + const { RefCell::new(None) }; } -/// Ensure this guard is held while deserializing `AbsolutePathBuf` values to -/// provide a base path for resolving relative paths. Because this relies on -/// thread-local storage, the deserialization must be single-threaded and -/// occur on the same thread that created the guard. +/// Ensure this guard is held while deserializing `AbsolutePathBuf` values so +/// the deserializer knows whether to resolve relative paths or only expand +/// `~/...`. Because this relies on thread-local storage, the deserialization +/// must be single-threaded and occur on the same thread that created the +/// guard. pub struct AbsolutePathBufGuard; impl AbsolutePathBufGuard { pub fn new(base_path: &Path) -> Self { - ABSOLUTE_PATH_BASE.with(|cell| { - *cell.borrow_mut() = Some(base_path.to_path_buf()); + Self::with_mode(base_path, AbsolutePathBufGuardMode::ResolveRelativePaths) + } + + pub fn home_expansion_only(base_path: &Path) -> Self { + Self::with_mode(base_path, AbsolutePathBufGuardMode::ExpandHomeOnly) + } + + fn with_mode(base_path: &Path, mode: AbsolutePathBufGuardMode) -> Self { + ABSOLUTE_PATH_GUARD_STATE.with(|cell| { + *cell.borrow_mut() = Some(AbsolutePathBufGuardState { + base_path: base_path.to_path_buf(), + mode, + }); }); Self } @@ -163,7 +209,7 @@ impl AbsolutePathBufGuard { impl Drop for AbsolutePathBufGuard { fn drop(&mut self) { - ABSOLUTE_PATH_BASE.with(|cell| { + ABSOLUTE_PATH_GUARD_STATE.with(|cell| { *cell.borrow_mut() = None; }); } @@ -175,10 +221,10 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf { D: Deserializer<'de>, { let path = PathBuf::deserialize(deserializer)?; - ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() { - Some(base) => { - Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?) - } + ABSOLUTE_PATH_GUARD_STATE.with(|cell| match cell.borrow().as_ref() { + Some(guard_state) => Ok(guard_state + .deserialize_absolute_path(path) + .map_err(SerdeError::custom)?), None if path.is_absolute() => { Self::from_absolute_path(path).map_err(SerdeError::custom) } @@ -260,6 +306,35 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } + #[cfg(not(target_os = "windows"))] + #[test] + fn home_expansion_only_guard_expands_home_directory_subpath() { + let Some(home) = home_dir() else { + return; + }; + let temp_dir = tempdir().expect("base dir"); + let abs_path_buf = { + let _guard = AbsolutePathBufGuard::home_expansion_only(temp_dir.path()); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + }; + assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); + } + + #[test] + fn home_expansion_only_guard_rejects_relative_paths() { + let temp_dir = tempdir().expect("base dir"); + let err = { + let _guard = AbsolutePathBufGuard::home_expansion_only(temp_dir.path()); + serde_json::from_str::("\"subdir/file.txt\"") + .expect_err("relative path with home-only guard should fail") + }; + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without an absolute path"), + "unexpected error: {err}", + ); + } + #[cfg(not(target_os = "windows"))] #[test] fn home_directory_double_slash_on_non_windows_is_expanded_in_deserialization() { From 3fb2305fcabcc68e1c1dc5a6c050577278b7d6a3 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Fri, 20 Mar 2026 14:11:26 -0700 Subject: [PATCH 02/14] config: validate merged MDM paths consistently --- codex-rs/core/src/agent/role.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 8 ++-- codex-rs/core/src/config/mod.rs | 4 +- codex-rs/core/src/config/service.rs | 16 ++++++-- codex-rs/core/src/config/service_tests.rs | 48 +++++++++++++++++++++++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 06c6eae1e6e4..fce64d1f2487 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -222,7 +222,7 @@ mod reload { config: &Config, config_layer_stack: &ConfigLayerStack, ) -> anyhow::Result { - Ok(deserialize_config_toml_with_base( + Ok(crate::config::deserialize_merged_config_toml( config_layer_stack.effective_config(), &config.codex_home, )?) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d3af8024974..2220e7cb1b3f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1778,10 +1778,10 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { ) .await?; let cfg = - deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) + deserialize_merged_config_toml(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - e + std::io::Error::new(std::io::ErrorKind::InvalidData, e) })?; assert_eq!( cfg.mcp_oauth_credentials_store, @@ -1908,10 +1908,10 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { .await?; let cfg = - deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) + deserialize_merged_config_toml(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - e + std::io::Error::new(std::io::ErrorKind::InvalidData, e) })?; assert_eq!(cfg.model.as_deref(), Some("managed_config")); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0ab0bd2a2643..ff6d6d358bbf 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -795,9 +795,9 @@ pub async fn load_config_as_toml_with_cli_overrides( .await?; let merged_toml = config_layer_stack.effective_config(); - let cfg = deserialize_config_toml_with_base(merged_toml, codex_home).map_err(|e| { + let cfg = deserialize_merged_config_toml(merged_toml, codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - e + std::io::Error::new(std::io::ErrorKind::InvalidData, e) })?; Ok(cfg) diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 58a2b38c8e61..868dab89ee08 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -242,9 +242,9 @@ impl ConfigService { .map_err(|err| ConfigServiceError::io("failed to load configuration", err))?; let toml_value = layers.effective_config(); - let cfg: ConfigToml = toml_value - .try_into() - .map_err(|err| ConfigServiceError::toml("failed to parse config.toml", err))?; + let cfg: ConfigToml = + crate::config::deserialize_merged_config_toml(toml_value, &self.codex_home) + .map_err(|err| ConfigServiceError::toml("failed to parse config.toml", err))?; Ok(cfg.into()) } @@ -371,7 +371,7 @@ impl ConfigService { let updated_layers = layers.with_user_config(&provided_path, user_config.clone()); let effective = updated_layers.effective_config(); - validate_config(&effective).map_err(|err| { + validate_merged_config(&effective, &self.codex_home).map_err(|err| { ConfigServiceError::write( ConfigWriteErrorCode::ConfigValidationError, format!("Invalid configuration: {err}"), @@ -619,6 +619,14 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { Ok(()) } +fn validate_merged_config( + value: &TomlValue, + config_base_dir: &Path, +) -> Result<(), toml::de::Error> { + crate::config::deserialize_merged_config_toml(value.clone(), config_base_dir)?; + Ok(()) +} + fn paths_match(expected: impl AsRef, provided: impl AsRef) -> bool { if let (Ok(expanded_expected), Ok(expanded_provided)) = ( path_utils::normalize_for_path_comparison(&expected), diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index 01dfd9f641df..782550cd1587 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -287,6 +287,54 @@ writable_roots = ["~/code"] Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> { + use base64::Engine; + + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?; + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let response = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("updated"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + assert_eq!(response.status, WriteStatus::Ok); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"), + "model = \"updated\"\n" + ); + + Ok(()) +} + #[tokio::test] async fn write_value_reports_override() { let tmp = tempdir().expect("tempdir"); From a4395c5038f53d54311357bcb893f260b6e5a76f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Mar 2026 16:31:14 -0700 Subject: [PATCH 03/14] Apply suggestions from code review Co-authored-by: Michael Bolin --- codex-rs/utils/absolute-path/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index af09dcb2bfe1..bd05faa1e2e2 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -315,7 +315,7 @@ mod tests { let temp_dir = tempdir().expect("base dir"); let abs_path_buf = { let _guard = AbsolutePathBufGuard::home_expansion_only(temp_dir.path()); - serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + serde_json::from_str::("\"~/code\"").expect("should deserialize") }; assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } From af32762c5c4622d32f3b196067748dea7a95ad84 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Mon, 23 Mar 2026 08:46:12 -0700 Subject: [PATCH 04/14] config: address review feedback --- codex-rs/utils/absolute-path/src/lib.rs | 101 +++++++++++------------- 1 file changed, 45 insertions(+), 56 deletions(-) diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index bd05faa1e2e2..994ffaa0fa22 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -26,14 +26,15 @@ impl AbsolutePathBuf { let Some(path_str) = path.to_str() else { return path.to_path_buf(); }; - if cfg!(not(target_os = "windows")) - && let Some(home) = home_dir() - { + if let Some(home) = home_dir() { if path_str == "~" { return home; } - if let Some(rest) = path_str.strip_prefix("~/") { - let rest = rest.trim_start_matches('/'); + if let Some(rest) = path_str + .strip_prefix("~/") + .or_else(|| path_str.strip_prefix("~\\")) + { + let rest = rest.trim_start_matches(['/', '\\']); if rest.is_empty() { return home; } @@ -148,35 +149,8 @@ enum AbsolutePathBufGuardMode { ExpandHomeOnly, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct AbsolutePathBufGuardState { - base_path: PathBuf, - mode: AbsolutePathBufGuardMode, -} - -impl AbsolutePathBufGuardState { - fn deserialize_absolute_path(&self, path: PathBuf) -> std::io::Result { - match self.mode { - AbsolutePathBufGuardMode::ResolveRelativePaths => { - AbsolutePathBuf::resolve_path_against_base(path, &self.base_path) - } - AbsolutePathBufGuardMode::ExpandHomeOnly => { - let expanded = AbsolutePathBuf::maybe_expand_home_directory(&path); - if expanded.is_absolute() { - AbsolutePathBuf::from_absolute_path(expanded) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "AbsolutePathBuf deserialized without an absolute path", - )) - } - } - } - } -} - thread_local! { - static ABSOLUTE_PATH_GUARD_STATE: RefCell> = + static ABSOLUTE_PATH_GUARD_STATE: RefCell> = const { RefCell::new(None) }; } @@ -198,15 +172,35 @@ impl AbsolutePathBufGuard { fn with_mode(base_path: &Path, mode: AbsolutePathBufGuardMode) -> Self { ABSOLUTE_PATH_GUARD_STATE.with(|cell| { - *cell.borrow_mut() = Some(AbsolutePathBufGuardState { - base_path: base_path.to_path_buf(), - mode, - }); + *cell.borrow_mut() = Some((base_path.to_path_buf(), mode)); }); Self } } +fn deserialize_absolute_path_with_guard( + path: PathBuf, + base_path: &Path, + mode: AbsolutePathBufGuardMode, +) -> std::io::Result { + match mode { + AbsolutePathBufGuardMode::ResolveRelativePaths => { + AbsolutePathBuf::resolve_path_against_base(path, base_path) + } + AbsolutePathBufGuardMode::ExpandHomeOnly => { + let expanded = AbsolutePathBuf::maybe_expand_home_directory(&path); + if expanded.is_absolute() { + AbsolutePathBuf::from_absolute_path(expanded) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "AbsolutePathBuf deserialized without an absolute path", + )) + } + } + } +} + impl Drop for AbsolutePathBufGuard { fn drop(&mut self) { ABSOLUTE_PATH_GUARD_STATE.with(|cell| { @@ -222,9 +216,10 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf { { let path = PathBuf::deserialize(deserializer)?; ABSOLUTE_PATH_GUARD_STATE.with(|cell| match cell.borrow().as_ref() { - Some(guard_state) => Ok(guard_state - .deserialize_absolute_path(path) - .map_err(SerdeError::custom)?), + Some((base_path, mode)) => { + Ok(deserialize_absolute_path_with_guard(path, base_path, *mode) + .map_err(SerdeError::custom)?) + } None if path.is_absolute() => { Self::from_absolute_path(path).map_err(SerdeError::custom) } @@ -278,9 +273,8 @@ mod tests { ); } - #[cfg(not(target_os = "windows"))] #[test] - fn home_directory_root_on_non_windows_is_expanded_in_deserialization() { + fn home_directory_root_is_expanded_in_deserialization() { let Some(home) = home_dir() else { return; }; @@ -292,9 +286,8 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.as_path()); } - #[cfg(not(target_os = "windows"))] #[test] - fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() { + fn home_directory_subpath_is_expanded_in_deserialization() { let Some(home) = home_dir() else { return; }; @@ -306,7 +299,6 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } - #[cfg(not(target_os = "windows"))] #[test] fn home_expansion_only_guard_expands_home_directory_subpath() { let Some(home) = home_dir() else { @@ -335,9 +327,8 @@ mod tests { ); } - #[cfg(not(target_os = "windows"))] #[test] - fn home_directory_double_slash_on_non_windows_is_expanded_in_deserialization() { + fn home_directory_double_slash_is_expanded_in_deserialization() { let Some(home) = home_dir() else { return; }; @@ -349,18 +340,16 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } - #[cfg(target_os = "windows")] #[test] - fn home_directory_on_windows_is_not_expanded_in_deserialization() { + fn home_directory_backslash_path_is_expanded_in_deserialization() { + let Some(home) = home_dir() else { + return; + }; let temp_dir = tempdir().expect("base dir"); - let base_dir = temp_dir.path(); let abs_path_buf = { - let _guard = AbsolutePathBufGuard::new(base_dir); - serde_json::from_str::("\"~/code\"").expect("failed to deserialize") + let _guard = AbsolutePathBufGuard::new(temp_dir.path()); + serde_json::from_str::(r#""~\\code""#).expect("failed to deserialize") }; - assert_eq!( - abs_path_buf.as_path(), - base_dir.join("~").join("code").as_path() - ); + assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } } From d806b22f5aa9bb5f187231cf95098a20a1424942 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Mon, 23 Mar 2026 14:05:08 -0700 Subject: [PATCH 05/14] test: cover parent-relative instructions file --- codex-rs/core/src/config_loader/tests.rs | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 6a2bc0b6b991..5a75503232ed 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -954,6 +954,41 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i Ok(()) } +#[tokio::test] +async fn user_config_model_instructions_file_parent_relative_to_codex_home() -> std::io::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + "model_instructions_file = \"../instr.md\"\n", + ) + .await?; + + let instructions_path = tmp.path().join("instr.md"); + tokio::fs::write(&instructions_path, "parent relative instructions").await?; + + let cwd = tmp.path().join("work"); + tokio::fs::create_dir_all(&cwd).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd), + ..ConfigOverrides::default() + }) + .build() + .await?; + + assert_eq!( + config.base_instructions.as_deref(), + Some("parent relative instructions") + ); + + Ok(()) +} + #[tokio::test] async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> { let tmp = tempdir()?; From 9bf259569bd3220ca92bce717058884de343c8da Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 08:59:41 -0700 Subject: [PATCH 06/14] test: widen windows multi-agent timing budget --- codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 80e5e5c2e8d1..0eef228538a7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -810,7 +810,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( )) ))); - timeout(Duration::from_secs(5), async { + timeout(Duration::from_secs(15), async { loop { let history_items = thread .codex From 4e276a541e063b33720515a188bd0d2f342ed2cf Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 09:21:42 -0700 Subject: [PATCH 07/14] test: avoid noexec tempdir for fake bwrap --- codex-rs/linux-sandbox/src/launcher.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 7d7e040844f0..915ffef7f1bf 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -209,8 +209,15 @@ exit 1 } fn write_fake_bwrap(contents: &str) -> TempPath { + // Bazel can mount the OS temp directory `noexec`, so prefer the current + // working directory for fake executables and fall back to the default temp + // dir outside that environment. + let temp_file = std::env::current_dir() + .ok() + .and_then(|dir| NamedTempFile::new_in(dir).ok()) + .unwrap_or_else(|| NamedTempFile::new().expect("temp file")); // Linux rejects exec-ing a file that is still open for writing. - let path = NamedTempFile::new().expect("temp file").into_temp_path(); + let path = temp_file.into_temp_path(); fs::write(&path, contents).expect("write fake bwrap"); let permissions = fs::Permissions::from_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); From 4b2abff843350d7665692d9fb3323b4686294cd1 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 10:24:36 -0700 Subject: [PATCH 08/14] config: make home expansion MDM-specific --- codex-rs/core/src/agent/role.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 4 ++-- codex-rs/core/src/config/mod.rs | 21 ++++++------------ codex-rs/core/src/config/service.rs | 22 ++++++------------- codex-rs/core/src/config_loader/mod.rs | 27 +++++++++++++++++++++++- 5 files changed, 43 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index fce64d1f2487..06c6eae1e6e4 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -222,7 +222,7 @@ mod reload { config: &Config, config_layer_stack: &ConfigLayerStack, ) -> anyhow::Result { - Ok(crate::config::deserialize_merged_config_toml( + Ok(deserialize_config_toml_with_base( config_layer_stack.effective_config(), &config.codex_home, )?) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2220e7cb1b3f..818fc36e246b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1778,7 +1778,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { ) .await?; let cfg = - deserialize_merged_config_toml(config_layer_stack.effective_config(), codex_home.path()) + deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); std::io::Error::new(std::io::ErrorKind::InvalidData, e) @@ -1908,7 +1908,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { .await?; let cfg = - deserialize_merged_config_toml(config_layer_stack.effective_config(), codex_home.path()) + deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); std::io::Error::new(std::io::ErrorKind::InvalidData, e) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ff6d6d358bbf..cbbd02390552 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -697,10 +697,11 @@ impl ConfigBuilder { .await?; let merged_toml = config_layer_stack.effective_config(); - // Config layers loaded from MDM can still contain `~/...` paths because - // there is no config-file directory to resolve them against. Allow home - // expansion here while still rejecting relative paths such as `./...`. - let config_toml = match deserialize_merged_config_toml(merged_toml, &codex_home) { + // Note that each layer in ConfigLayerStack should have resolved + // relative paths to absolute paths based on the parent folder of the + // respective config file, so we should be safe to deserialize without + // AbsolutePathBufGuard here. + let config_toml: ConfigToml = match merged_toml.try_into() { Ok(config_toml) => config_toml, Err(err) => { if let Some(config_error) = @@ -795,9 +796,9 @@ pub async fn load_config_as_toml_with_cli_overrides( .await?; let merged_toml = config_layer_stack.effective_config(); - let cfg = deserialize_merged_config_toml(merged_toml, codex_home).map_err(|e| { + let cfg = deserialize_config_toml_with_base(merged_toml, codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Ok(cfg) @@ -815,14 +816,6 @@ pub(crate) fn deserialize_config_toml_with_base( .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } -pub(crate) fn deserialize_merged_config_toml( - root_value: TomlValue, - config_base_dir: &Path, -) -> Result { - let _guard = AbsolutePathBufGuard::home_expansion_only(config_base_dir); - root_value.try_into() -} - fn load_catalog_json(path: &AbsolutePathBuf) -> std::io::Result { let file_contents = std::fs::read_to_string(path)?; let catalog = serde_json::from_str::(&file_contents).map_err(|err| { diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 868dab89ee08..279af666c1e8 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -169,9 +169,9 @@ impl ConfigService { let effective = layers.effective_config(); - let effective_config_toml = - crate::config::deserialize_merged_config_toml(effective, &self.codex_home) - .map_err(|err| ConfigServiceError::toml("invalid configuration", err))?; + let effective_config_toml: ConfigToml = effective + .try_into() + .map_err(|err| ConfigServiceError::toml("invalid configuration", err))?; let json_value = serde_json::to_value(&effective_config_toml) .map_err(|err| ConfigServiceError::json("failed to serialize configuration", err))?; @@ -242,9 +242,9 @@ impl ConfigService { .map_err(|err| ConfigServiceError::io("failed to load configuration", err))?; let toml_value = layers.effective_config(); - let cfg: ConfigToml = - crate::config::deserialize_merged_config_toml(toml_value, &self.codex_home) - .map_err(|err| ConfigServiceError::toml("failed to parse config.toml", err))?; + let cfg: ConfigToml = toml_value + .try_into() + .map_err(|err| ConfigServiceError::toml("failed to parse config.toml", err))?; Ok(cfg.into()) } @@ -371,7 +371,7 @@ impl ConfigService { let updated_layers = layers.with_user_config(&provided_path, user_config.clone()); let effective = updated_layers.effective_config(); - validate_merged_config(&effective, &self.codex_home).map_err(|err| { + validate_config(&effective).map_err(|err| { ConfigServiceError::write( ConfigWriteErrorCode::ConfigValidationError, format!("Invalid configuration: {err}"), @@ -619,14 +619,6 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { Ok(()) } -fn validate_merged_config( - value: &TomlValue, - config_base_dir: &Path, -) -> Result<(), toml::de::Error> { - crate::config::deserialize_merged_config_toml(value.clone(), config_base_dir)?; - Ok(()) -} - fn paths_match(expected: impl AsRef, provided: impl AsRef) -> bool { if let (Ok(expanded_expected), Ok(expanded_provided)) = ( path_utils::normalize_for_path_comparison(&expected), diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0c426b155f7e..e4d6872ff24e 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -285,9 +285,11 @@ pub async fn load_config_layers_state( )); } if let Some(config) = managed_config_from_mdm { + let managed_config = + expand_home_directory_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, + managed_config, config.raw_toml, )); } @@ -736,6 +738,29 @@ pub(crate) fn resolve_relative_paths_in_config_toml( )) } +fn expand_home_directory_paths_in_config_toml( + value_from_config_toml: TomlValue, + base_dir: &Path, +) -> io::Result { + let _guard = AbsolutePathBufGuard::home_expansion_only(base_dir); + let Ok(resolved) = value_from_config_toml.clone().try_into::() else { + return Ok(value_from_config_toml); + }; + drop(_guard); + + let resolved_value = TomlValue::try_from(resolved).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to serialize resolved config: {e}"), + ) + })?; + + Ok(copy_shape_from_original( + &value_from_config_toml, + &resolved_value, + )) +} + /// Ensure that every field in `original` is present in the returned /// `toml::Value`, taking the value from `resolved` where possible. This ensures /// the fields that we "removed" during the serialize/deserialize round-trip in From 97998daf152daac181ade65fd85a5e84e1727856 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 11:25:26 -0700 Subject: [PATCH 09/14] config: scope tilde expansion to MDM writable_roots --- codex-rs/core/src/config/config_tests.rs | 4 +- codex-rs/core/src/config/service_tests.rs | 50 ------- codex-rs/core/src/config_loader/mod.rs | 55 +++++--- codex-rs/core/src/config_loader/tests.rs | 35 ----- .../src/tools/handlers/multi_agents_tests.rs | 2 +- codex-rs/linux-sandbox/src/launcher.rs | 9 +- codex-rs/utils/absolute-path/src/lib.rs | 126 +++++------------- 7 files changed, 73 insertions(+), 208 deletions(-) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 818fc36e246b..7d3af8024974 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1781,7 +1781,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; assert_eq!( cfg.mcp_oauth_credentials_store, @@ -1911,7 +1911,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { deserialize_config_toml_with_base(config_layer_stack.effective_config(), codex_home.path()) .map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; assert_eq!(cfg.model.as_deref(), Some("managed_config")); diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index 782550cd1587..a1897c9ddd11 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -237,56 +237,6 @@ async fn read_includes_origins_and_layers() { )); } -#[cfg(target_os = "macos")] -#[tokio::test] -async fn read_expands_home_directory_paths_from_managed_preferences() -> Result<()> { - use base64::Engine; - - let Some(home) = dirs::home_dir() else { - return Ok(()); - }; - let tmp = tempdir().expect("tempdir"); - - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(tmp.path().join("managed_config.toml")), - managed_preferences_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" -sandbox_mode = "workspace-write" -[sandbox_workspace_write] -writable_roots = ["~/code"] -"# - .as_bytes(), - ), - ), - macos_managed_config_requirements_base64: None, - }, - CloudRequirementsLoader::default(), - ); - - let response = service - .read(ConfigReadParams { - include_layers: false, - cwd: None, - }) - .await - .expect("config read succeeds"); - - assert_eq!( - response - .config - .sandbox_workspace_write - .expect("workspace-write settings") - .writable_roots, - vec![home.join("code")] - ); - - Ok(()) -} - #[cfg(target_os = "macos")] #[tokio::test] async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index e4d6872ff24e..212722b91b3d 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -286,7 +286,7 @@ pub async fn load_config_layers_state( } if let Some(config) = managed_config_from_mdm { let managed_config = - expand_home_directory_paths_in_config_toml(config.managed_config, codex_home)?; + expand_home_directory_paths_in_mdm_writable_roots(config.managed_config)?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, managed_config, @@ -738,27 +738,48 @@ pub(crate) fn resolve_relative_paths_in_config_toml( )) } -fn expand_home_directory_paths_in_config_toml( - value_from_config_toml: TomlValue, - base_dir: &Path, +fn expand_home_directory_paths_in_mdm_writable_roots( + mut value_from_config_toml: TomlValue, ) -> io::Result { - let _guard = AbsolutePathBufGuard::home_expansion_only(base_dir); - let Ok(resolved) = value_from_config_toml.clone().try_into::() else { + let Some(home_dir) = dirs::home_dir() else { + return Ok(value_from_config_toml); + }; + let Some(writable_roots) = value_from_config_toml + .get_mut("sandbox_workspace_write") + .and_then(TomlValue::as_table_mut) + .and_then(|section| section.get_mut("writable_roots")) + .and_then(TomlValue::as_array_mut) + else { return Ok(value_from_config_toml); }; - drop(_guard); - let resolved_value = TomlValue::try_from(resolved).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("Failed to serialize resolved config: {e}"), - ) - })?; + for writable_root in writable_roots { + let Some(path) = writable_root.as_str() else { + continue; + }; + if let Some(rest) = path + .strip_prefix("~/") + .or_else(|| path.strip_prefix("~\\")) + .map(|rest| rest.trim_start_matches(['/', '\\'])) + .or_else(|| (path == "~").then_some("")) + { + let expanded = if rest.is_empty() { + home_dir.to_path_buf() + } else { + home_dir.join(rest) + }; + *writable_root = TomlValue::String(expanded.to_string_lossy().into_owned()); + continue; + } + if Path::new(path).is_relative() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AbsolutePathBuf deserialized without an absolute path", + )); + } + } - Ok(copy_shape_from_original( - &value_from_config_toml, - &resolved_value, - )) + Ok(value_from_config_toml) } /// Ensure that every field in `original` is present in the returned diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 5a75503232ed..6a2bc0b6b991 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -954,41 +954,6 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i Ok(()) } -#[tokio::test] -async fn user_config_model_instructions_file_parent_relative_to_codex_home() -> std::io::Result<()> -{ - let tmp = tempdir()?; - let codex_home = tmp.path().join("home"); - tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write( - codex_home.join(CONFIG_TOML_FILE), - "model_instructions_file = \"../instr.md\"\n", - ) - .await?; - - let instructions_path = tmp.path().join("instr.md"); - tokio::fs::write(&instructions_path, "parent relative instructions").await?; - - let cwd = tmp.path().join("work"); - tokio::fs::create_dir_all(&cwd).await?; - - let config = ConfigBuilder::default() - .codex_home(codex_home) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd), - ..ConfigOverrides::default() - }) - .build() - .await?; - - assert_eq!( - config.base_instructions.as_deref(), - Some("parent relative instructions") - ); - - Ok(()) -} - #[tokio::test] async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> { let tmp = tempdir()?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 0eef228538a7..80e5e5c2e8d1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -810,7 +810,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( )) ))); - timeout(Duration::from_secs(15), async { + timeout(Duration::from_secs(5), async { loop { let history_items = thread .codex diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 915ffef7f1bf..7d7e040844f0 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -209,15 +209,8 @@ exit 1 } fn write_fake_bwrap(contents: &str) -> TempPath { - // Bazel can mount the OS temp directory `noexec`, so prefer the current - // working directory for fake executables and fall back to the default temp - // dir outside that environment. - let temp_file = std::env::current_dir() - .ok() - .and_then(|dir| NamedTempFile::new_in(dir).ok()) - .unwrap_or_else(|| NamedTempFile::new().expect("temp file")); // Linux rejects exec-ing a file that is still open for writing. - let path = temp_file.into_temp_path(); + let path = NamedTempFile::new().expect("temp file").into_temp_path(); fs::write(&path, contents).expect("write fake bwrap"); let permissions = fs::Permissions::from_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 994ffaa0fa22..1ba1c40cfb2a 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -26,15 +26,14 @@ impl AbsolutePathBuf { let Some(path_str) = path.to_str() else { return path.to_path_buf(); }; - if let Some(home) = home_dir() { + if cfg!(not(target_os = "windows")) + && let Some(home) = home_dir() + { if path_str == "~" { return home; } - if let Some(rest) = path_str - .strip_prefix("~/") - .or_else(|| path_str.strip_prefix("~\\")) - { - let rest = rest.trim_start_matches(['/', '\\']); + if let Some(rest) = path_str.strip_prefix("~/") { + let rest = rest.trim_start_matches('/'); if rest.is_empty() { return home; } @@ -143,67 +142,28 @@ impl TryFrom for AbsolutePathBuf { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AbsolutePathBufGuardMode { - ResolveRelativePaths, - ExpandHomeOnly, -} - thread_local! { - static ABSOLUTE_PATH_GUARD_STATE: RefCell> = - const { RefCell::new(None) }; + static ABSOLUTE_PATH_BASE: RefCell> = const { RefCell::new(None) }; } -/// Ensure this guard is held while deserializing `AbsolutePathBuf` values so -/// the deserializer knows whether to resolve relative paths or only expand -/// `~/...`. Because this relies on thread-local storage, the deserialization -/// must be single-threaded and occur on the same thread that created the -/// guard. +/// Ensure this guard is held while deserializing `AbsolutePathBuf` values to +/// provide a base path for resolving relative paths. Because this relies on +/// thread-local storage, the deserialization must be single-threaded and +/// occur on the same thread that created the guard. pub struct AbsolutePathBufGuard; impl AbsolutePathBufGuard { pub fn new(base_path: &Path) -> Self { - Self::with_mode(base_path, AbsolutePathBufGuardMode::ResolveRelativePaths) - } - - pub fn home_expansion_only(base_path: &Path) -> Self { - Self::with_mode(base_path, AbsolutePathBufGuardMode::ExpandHomeOnly) - } - - fn with_mode(base_path: &Path, mode: AbsolutePathBufGuardMode) -> Self { - ABSOLUTE_PATH_GUARD_STATE.with(|cell| { - *cell.borrow_mut() = Some((base_path.to_path_buf(), mode)); + ABSOLUTE_PATH_BASE.with(|cell| { + *cell.borrow_mut() = Some(base_path.to_path_buf()); }); Self } } -fn deserialize_absolute_path_with_guard( - path: PathBuf, - base_path: &Path, - mode: AbsolutePathBufGuardMode, -) -> std::io::Result { - match mode { - AbsolutePathBufGuardMode::ResolveRelativePaths => { - AbsolutePathBuf::resolve_path_against_base(path, base_path) - } - AbsolutePathBufGuardMode::ExpandHomeOnly => { - let expanded = AbsolutePathBuf::maybe_expand_home_directory(&path); - if expanded.is_absolute() { - AbsolutePathBuf::from_absolute_path(expanded) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "AbsolutePathBuf deserialized without an absolute path", - )) - } - } - } -} - impl Drop for AbsolutePathBufGuard { fn drop(&mut self) { - ABSOLUTE_PATH_GUARD_STATE.with(|cell| { + ABSOLUTE_PATH_BASE.with(|cell| { *cell.borrow_mut() = None; }); } @@ -215,10 +175,9 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf { D: Deserializer<'de>, { let path = PathBuf::deserialize(deserializer)?; - ABSOLUTE_PATH_GUARD_STATE.with(|cell| match cell.borrow().as_ref() { - Some((base_path, mode)) => { - Ok(deserialize_absolute_path_with_guard(path, base_path, *mode) - .map_err(SerdeError::custom)?) + ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() { + Some(base) => { + Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?) } None if path.is_absolute() => { Self::from_absolute_path(path).map_err(SerdeError::custom) @@ -273,8 +232,9 @@ mod tests { ); } + #[cfg(not(target_os = "windows"))] #[test] - fn home_directory_root_is_expanded_in_deserialization() { + fn home_directory_root_on_non_windows_is_expanded_in_deserialization() { let Some(home) = home_dir() else { return; }; @@ -286,8 +246,9 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.as_path()); } + #[cfg(not(target_os = "windows"))] #[test] - fn home_directory_subpath_is_expanded_in_deserialization() { + fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() { let Some(home) = home_dir() else { return; }; @@ -299,36 +260,9 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } + #[cfg(not(target_os = "windows"))] #[test] - fn home_expansion_only_guard_expands_home_directory_subpath() { - let Some(home) = home_dir() else { - return; - }; - let temp_dir = tempdir().expect("base dir"); - let abs_path_buf = { - let _guard = AbsolutePathBufGuard::home_expansion_only(temp_dir.path()); - serde_json::from_str::("\"~/code\"").expect("should deserialize") - }; - assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); - } - - #[test] - fn home_expansion_only_guard_rejects_relative_paths() { - let temp_dir = tempdir().expect("base dir"); - let err = { - let _guard = AbsolutePathBufGuard::home_expansion_only(temp_dir.path()); - serde_json::from_str::("\"subdir/file.txt\"") - .expect_err("relative path with home-only guard should fail") - }; - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without an absolute path"), - "unexpected error: {err}", - ); - } - - #[test] - fn home_directory_double_slash_is_expanded_in_deserialization() { + fn home_directory_double_slash_on_non_windows_is_expanded_in_deserialization() { let Some(home) = home_dir() else { return; }; @@ -340,16 +274,18 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } + #[cfg(target_os = "windows")] #[test] - fn home_directory_backslash_path_is_expanded_in_deserialization() { - let Some(home) = home_dir() else { - return; - }; + fn home_directory_on_windows_is_not_expanded_in_deserialization() { let temp_dir = tempdir().expect("base dir"); + let base_dir = temp_dir.path(); let abs_path_buf = { - let _guard = AbsolutePathBufGuard::new(temp_dir.path()); - serde_json::from_str::(r#""~\\code""#).expect("failed to deserialize") + let _guard = AbsolutePathBufGuard::new(base_dir); + serde_json::from_str::("\"~/code\"").expect("failed to deserialize") }; - assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); + assert_eq!( + abs_path_buf.as_path(), + base_dir.join("~").join("code").as_path() + ); } } From 74fda242d3651f0a43ec8657bdbc7bde426dce0e Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 11:38:56 -0700 Subject: [PATCH 10/14] config: reuse existing resolver for MDM tilde paths --- codex-rs/core/src/config_loader/mod.rs | 46 +------------------------- 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 212722b91b3d..4102c07a82f4 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -286,7 +286,7 @@ pub async fn load_config_layers_state( } if let Some(config) = managed_config_from_mdm { let managed_config = - expand_home_directory_paths_in_mdm_writable_roots(config.managed_config)?; + resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, managed_config, @@ -738,50 +738,6 @@ pub(crate) fn resolve_relative_paths_in_config_toml( )) } -fn expand_home_directory_paths_in_mdm_writable_roots( - mut value_from_config_toml: TomlValue, -) -> io::Result { - let Some(home_dir) = dirs::home_dir() else { - return Ok(value_from_config_toml); - }; - let Some(writable_roots) = value_from_config_toml - .get_mut("sandbox_workspace_write") - .and_then(TomlValue::as_table_mut) - .and_then(|section| section.get_mut("writable_roots")) - .and_then(TomlValue::as_array_mut) - else { - return Ok(value_from_config_toml); - }; - - for writable_root in writable_roots { - let Some(path) = writable_root.as_str() else { - continue; - }; - if let Some(rest) = path - .strip_prefix("~/") - .or_else(|| path.strip_prefix("~\\")) - .map(|rest| rest.trim_start_matches(['/', '\\'])) - .or_else(|| (path == "~").then_some("")) - { - let expanded = if rest.is_empty() { - home_dir.to_path_buf() - } else { - home_dir.join(rest) - }; - *writable_root = TomlValue::String(expanded.to_string_lossy().into_owned()); - continue; - } - if Path::new(path).is_relative() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "AbsolutePathBuf deserialized without an absolute path", - )); - } - } - - Ok(value_from_config_toml) -} - /// Ensure that every field in `original` is present in the returned /// `toml::Value`, taking the value from `resolved` where possible. This ensures /// the fields that we "removed" during the serialize/deserialize round-trip in From 0d845742597653188e16e9254cbd7d37ce2d80a7 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 12:16:02 -0700 Subject: [PATCH 11/14] test: accept zsh-fork decline timeout output --- codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index c8ae882e236a..cce1ac8fd8c1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -676,7 +676,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() if let Some(output) = aggregated_output.as_deref() { assert!( output == "exec command rejected by user" - || output.contains("sandbox denied exec error"), + || output.contains("sandbox denied exec error") + || output == "sandbox error: command timed out", "unexpected aggregated output: {output}" ); } From 67d15b5b3e8bc649e4da0466ecb9b372b044184a Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 13:13:40 -0700 Subject: [PATCH 12/14] test: deflake current CI failures --- codex-rs/app-server-client/src/lib.rs | 10 +++++++++- codex-rs/app-server/tests/suite/v2/plugin_read.rs | 3 +++ codex-rs/exec/tests/suite/resume.rs | 4 ++++ codex-rs/linux-sandbox/src/launcher.rs | 6 +++++- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 115e9808f0fb..bc52e0987d1c 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -954,7 +954,15 @@ mod tests { }) .await .expect("typed request should succeed"); - client.shutdown().await.expect("shutdown should complete"); + let shutdown_result = client.shutdown().await; + assert!( + shutdown_result.is_ok() + || matches!( + shutdown_result.as_ref().map_err(|error| error.kind()), + Err(ErrorKind::BrokenPipe) + ), + "shutdown should complete: {shutdown_result:?}" + ); } #[tokio::test] diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 977ceb26912e..ab5dda6662cb 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -43,6 +43,9 @@ use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +#[cfg(windows)] +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20); +#[cfg(not(windows))] const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index a85183620edb..7c6772c1aec7 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -195,6 +195,10 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( let marker2 = format!("resume-last-json-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); + // `resume --last` sorts by second-granularity rollout metadata, so sleep to + // avoid tying the original session creation time on fast CI. + std::thread::sleep(std::time::Duration::from_millis(1100)); + test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 7d7e040844f0..66a170aa95c0 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -210,7 +210,11 @@ exit 1 fn write_fake_bwrap(contents: &str) -> TempPath { // Linux rejects exec-ing a file that is still open for writing. - let path = NamedTempFile::new().expect("temp file").into_temp_path(); + let temp_file = std::env::current_dir() + .ok() + .and_then(|cwd| NamedTempFile::new_in(cwd).ok()) + .unwrap_or_else(|| NamedTempFile::new().expect("temp file")); + let path = temp_file.into_temp_path(); fs::write(&path, contents).expect("write fake bwrap"); let permissions = fs::Permissions::from_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); From 7c8c5652c4f8b0b32a787454d2e1880c03bf36c8 Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 13:19:32 -0700 Subject: [PATCH 13/14] test: drop windows-specific plugin_read timeout --- codex-rs/app-server/tests/suite/v2/plugin_read.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index ab5dda6662cb..977ceb26912e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -43,9 +43,6 @@ use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; -#[cfg(windows)] -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20); -#[cfg(not(windows))] const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] From cba7a5e058a3a32dc0eb4372788f531acef8aaaf Mon Sep 17 00:00:00 2001 From: Eva Wong Date: Tue, 24 Mar 2026 13:21:45 -0700 Subject: [PATCH 14/14] revert: drop unrelated CI deflakes --- codex-rs/app-server-client/src/lib.rs | 10 +--------- .../app-server/tests/suite/v2/turn_start_zsh_fork.rs | 3 +-- codex-rs/exec/tests/suite/resume.rs | 4 ---- codex-rs/linux-sandbox/src/launcher.rs | 6 +----- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bc52e0987d1c..115e9808f0fb 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -954,15 +954,7 @@ mod tests { }) .await .expect("typed request should succeed"); - let shutdown_result = client.shutdown().await; - assert!( - shutdown_result.is_ok() - || matches!( - shutdown_result.as_ref().map_err(|error| error.kind()), - Err(ErrorKind::BrokenPipe) - ), - "shutdown should complete: {shutdown_result:?}" - ); + client.shutdown().await.expect("shutdown should complete"); } #[tokio::test] diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index cce1ac8fd8c1..c8ae882e236a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -676,8 +676,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() if let Some(output) = aggregated_output.as_deref() { assert!( output == "exec command rejected by user" - || output.contains("sandbox denied exec error") - || output == "sandbox error: command timed out", + || output.contains("sandbox denied exec error"), "unexpected aggregated output: {output}" ); } diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 7c6772c1aec7..a85183620edb 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -195,10 +195,6 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( let marker2 = format!("resume-last-json-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - // `resume --last` sorts by second-granularity rollout metadata, so sleep to - // avoid tying the original session creation time on fast CI. - std::thread::sleep(std::time::Duration::from_millis(1100)); - test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 66a170aa95c0..7d7e040844f0 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -210,11 +210,7 @@ exit 1 fn write_fake_bwrap(contents: &str) -> TempPath { // Linux rejects exec-ing a file that is still open for writing. - let temp_file = std::env::current_dir() - .ok() - .and_then(|cwd| NamedTempFile::new_in(cwd).ok()) - .unwrap_or_else(|| NamedTempFile::new().expect("temp file")); - let path = temp_file.into_temp_path(); + let path = NamedTempFile::new().expect("temp file").into_temp_path(); fs::write(&path, contents).expect("write fake bwrap"); let permissions = fs::Permissions::from_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod fake bwrap");