diff --git a/codex-rs/config/src/host_name.rs b/codex-rs/config/src/host_name.rs index dcd34b0ba..eb67b7b8d 100644 --- a/codex-rs/config/src/host_name.rs +++ b/codex-rs/config/src/host_name.rs @@ -10,6 +10,8 @@ use winapi_util::sysinfo::get_computer_name; static HOST_NAME: LazyLock> = LazyLock::new(compute_host_name); +/// Returns a process-cached canonical hostname, falling back to the normalized +/// kernel hostname. The first call on Unix may perform blocking DNS resolution. pub fn host_name() -> Option { HOST_NAME.clone() } diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index d92024e11..7329a66f6 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -54,7 +54,7 @@ pub(super) struct ComposableRequirementsLayer { impl ComposableRequirementsLayer { pub(super) fn from_entry( layer: RequirementsLayerEntry, - hostname: Option<&str>, + hostname_resolver: &dyn Fn() -> Option, ) -> Result { let RequirementsLayerEntry { source, @@ -70,7 +70,13 @@ impl ComposableRequirementsLayer { (regular_toml, requirements) }; - requirements.apply_remote_sandbox_config(hostname); + // Hostname lookup is configuration-driven and may block on DNS, so only + // resolve it when this layer contains hostname-based sandbox selectors. + let hostname = requirements + .remote_sandbox_config + .as_ref() + .and_then(|_| hostname_resolver()); + requirements.apply_remote_sandbox_config(hostname.as_deref()); materialize_remote_sandbox_config(&mut regular_toml, &requirements)?; strip_special_fields(&mut regular_toml); diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index a7bcab279..a7d8245ec 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -18,6 +18,7 @@ use crate::ConfigRequirementsWithSources; use crate::RequirementSource; use crate::Sourced; use crate::merge::merge_toml_values; +use std::cell::OnceCell; use std::io; use thiserror::Error; use toml::Value as TomlValue; @@ -57,29 +58,59 @@ impl From for io::Error { pub fn compose_requirements( layers: impl IntoIterator, ) -> Result, RequirementsCompositionError> { - let hostname = crate::host_name(); - compose_requirements_for_hostname(layers, hostname.as_deref()) + compose_requirements_with_hostname_resolver(layers, crate::host_name) } +#[cfg(test)] pub(super) fn compose_requirements_for_hostname( layers: impl IntoIterator, hostname: Option<&str>, ) -> Result, RequirementsCompositionError> { - compose_requirements_for_hostname_and_hook_directory( + let hostname = hostname.map(str::to_string); + compose_requirements_with_hostname_resolver_and_hook_directory( layers, - hostname, + move || hostname.clone(), HookDirectoryField::current_platform(), ) } +#[cfg(test)] pub(super) fn compose_requirements_for_hostname_and_hook_directory( layers: impl IntoIterator, hostname: Option<&str>, hook_directory_field: HookDirectoryField, ) -> Result, RequirementsCompositionError> { + let hostname = hostname.map(str::to_string); + compose_requirements_with_hostname_resolver_and_hook_directory( + layers, + move || hostname.clone(), + hook_directory_field, + ) +} + +fn compose_requirements_with_hostname_resolver( + layers: impl IntoIterator, + hostname_resolver: impl Fn() -> Option, +) -> Result, RequirementsCompositionError> { + compose_requirements_with_hostname_resolver_and_hook_directory( + layers, + hostname_resolver, + HookDirectoryField::current_platform(), + ) +} + +fn compose_requirements_with_hostname_resolver_and_hook_directory( + layers: impl IntoIterator, + hostname_resolver: impl Fn() -> Option, + hook_directory_field: HookDirectoryField, +) -> Result, RequirementsCompositionError> { + // Evaluate every layer in this composition against the same hostname while + // keeping resolution lazy when no layer needs remote sandbox matching. + let hostname = OnceCell::new(); + let cached_hostname_resolver = || hostname.get_or_init(&hostname_resolver).clone(); let mut stack = RequirementsLayerStack::new(hook_directory_field); for layer in layers { - stack.add_layer(layer, hostname)?; + stack.add_layer(layer, &cached_hostname_resolver)?; } stack.compose() } @@ -100,10 +131,12 @@ impl RequirementsLayerStack { fn add_layer( &mut self, layer: RequirementsLayerEntry, - hostname: Option<&str>, + hostname_resolver: &dyn Fn() -> Option, ) -> Result<(), RequirementsCompositionError> { - self.layers - .push(ComposableRequirementsLayer::from_entry(layer, hostname)?); + self.layers.push(ComposableRequirementsLayer::from_entry( + layer, + hostname_resolver, + )?); Ok(()) } diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 7be51e2b3..fafd0523e 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -3,6 +3,7 @@ use super::super::hooks::HookDirectoryField; use super::RequirementsCompositionError; use super::compose_requirements_for_hostname; use super::compose_requirements_for_hostname_and_hook_directory; +use super::compose_requirements_with_hostname_resolver; use crate::ConfigRequirementsToml; use crate::ConfigRequirementsWithSources; use crate::RequirementSource; @@ -10,6 +11,7 @@ use crate::Sourced; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::cell::Cell; use std::collections::BTreeMap; fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry { @@ -552,6 +554,81 @@ allowed_sandbox_modes = ["read-only"] ); } +#[test] +fn hostname_resolver_is_not_called_without_remote_sandbox_config() { + let calls = Cell::::default(); + let composed = compose_requirements_with_hostname_resolver( + vec![layer( + "req", + "No remote selector", + r#" +allowed_sandbox_modes = ["read-only"] +"#, + )], + || { + calls.set(calls.get() + 1); + Some("build-01.example.com".to_string()) + }, + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!(calls.get(), 0); + assert_eq!( + composed, + expected_requirements( + r#" +allowed_sandbox_modes = ["read-only"] +"# + ) + ); +} + +#[test] +fn hostname_resolver_is_called_once_for_multiple_remote_sandbox_layers() { + let calls = Cell::::default(); + let composed = compose_requirements_with_hostname_resolver( + vec![ + layer( + "req_low", + "Low", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["build-*.example.com"] +allowed_sandbox_modes = ["read-only"] +"#, + ), + layer( + "req_high", + "High", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["build-*.example.com"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + ], + || { + calls.set(calls.get() + 1); + Some("build-01.example.com".to_string()) + }, + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!(calls.get(), 1); + assert_eq!( + composed, + expected_requirements( + r#" +allowed_sandbox_modes = ["workspace-write"] +"# + ) + ); +} + #[test] fn rules_are_appended_in_priority_order() { let composed = compose(vec![