diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 057498eae..b085bb68b 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -831,6 +831,7 @@ "dispatch2_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bitflags\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"block2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.80\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"alloc\":[],\"block2\":[\"dep:block2\"],\"default\":[\"std\",\"block2\",\"libc\",\"objc2\"],\"libc\":[\"dep:libc\"],\"objc2\":[\"dep:objc2\"],\"std\":[\"alloc\"]}}", "display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}", "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "dns-lookup_3.0.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Networking_WinSock\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", "downcast-rs_1.2.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "dtor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 69a9f51b0..72d23456b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -449,7 +449,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -1372,7 +1372,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.1", ] [[package]] @@ -1877,7 +1877,10 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "codex-utils-path", + "dns-lookup", "futures", + "gethostname", + "libc", "multimap", "pretty_assertions", "schemars 0.8.22", @@ -1892,6 +1895,7 @@ dependencies = [ "toml_edit 0.24.0+spec-1.1.0", "tracing", "wildmatch", + "winapi-util", ] [[package]] @@ -4324,6 +4328,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dns-lookup" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e39034cee21a2f5bbb66ba0e3689819c4bb5d00382a282006e802a7ffa6c41d" +dependencies = [ + "cfg-if", + "libc", + "socket2 0.6.2", + "windows-sys 0.60.2", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -4548,7 +4564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5110,7 +5126,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5554,7 +5570,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -5640,7 +5656,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -6060,7 +6076,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7550,7 +7566,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -8237,7 +8253,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -8274,9 +8290,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -9118,7 +9134,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10577,7 +10593,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index fd203e625..4c15e4707 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -236,6 +236,7 @@ deno_core_icudata = "0.77.0" derive_more = "2" diffy = "0.4.2" dirs = "6" +dns-lookup = "3.0.1" dotenvy = "0.15.7" dunce = "1.0.4" ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] } @@ -373,6 +374,7 @@ webbrowser = "1.0" which = "8" whoami = "1.6.1" wildmatch = "2.6.1" +winapi-util = "0.1.11" zip = "2.4.2" zstd = "0.13" diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 34c71ac3e..64d60bee3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6577,6 +6577,7 @@ impl CodexMessageProcessor { } }; let cli_overrides = self.current_cli_overrides(); + let host_name = codex_config::host_name(); let mut data = Vec::new(); for cwd in cwds { let extra_roots = extra_roots_by_cwd @@ -6605,6 +6606,7 @@ impl CodexMessageProcessor { LoaderOverrides::default(), CloudRequirementsLoader::default(), self.thread_config_loader.as_ref(), + host_name.as_deref(), ) .await { diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index feb1cc788..c57d3893c 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -108,6 +108,7 @@ impl ConfigApi { self.current_cli_overrides(), self.loader_overrides.clone(), self.current_cloud_requirements(), + codex_config::host_name(), ) } @@ -563,6 +564,7 @@ mod tests { CoreSandboxModeRequirement::ReadOnly, CoreSandboxModeRequirement::ExternalSandbox, ]), + remote_sandbox_config: None, allowed_web_search_modes: Some(vec![ codex_core::config_loader::WebSearchModeRequirement::Cached, ]), @@ -675,6 +677,7 @@ mod tests { allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -733,6 +736,7 @@ mod tests { allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 60a7af20e..23be6f410 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1187,6 +1187,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1217,6 +1218,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1247,6 +1249,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1294,6 +1297,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1377,6 +1381,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1450,6 +1455,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1521,6 +1527,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1719,6 +1726,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1755,6 +1763,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1811,6 +1820,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1862,6 +1872,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1917,6 +1928,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -1973,6 +1985,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -2029,6 +2042,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -2118,6 +2132,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, @@ -2146,6 +2161,7 @@ enabled = false allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 4d70de2fd..a7a50b072 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -19,6 +19,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } +gethostname = { workspace = true } multimap = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -32,6 +33,13 @@ toml_edit = { workspace = true } tracing = { workspace = true } wildmatch = { workspace = true } +[target.'cfg(unix)'.dependencies] +dns-lookup = { workspace = true } +libc = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +winapi-util = { workspace = true } + [dev-dependencies] pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 7cf22f69e..57ee0601e 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -11,6 +11,7 @@ use serde::de::value::Error as ValueDeserializerError; use serde::de::value::StrDeserializer; use std::collections::BTreeMap; use std::fmt; +use wildmatch::WildMatchPattern; use super::requirements_exec_policy::RequirementsExecPolicy; use super::requirements_exec_policy::RequirementsExecPolicyToml; @@ -620,6 +621,7 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, + pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, @@ -633,6 +635,12 @@ pub struct ConfigRequirementsToml { pub guardian_policy_config: Option, } +#[derive(Deserialize, Debug, Clone, PartialEq)] +pub struct RemoteSandboxConfigToml { + pub hostname_patterns: Vec, + pub allowed_sandbox_modes: Vec, +} + /// Value paired with the requirement source it came from, for better error /// messages. #[derive(Debug, Clone, PartialEq)] @@ -693,6 +701,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, + remote_sandbox_config: _, allowed_web_search_modes: _, feature_requirements: _, mcp_servers: _, @@ -759,6 +768,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), @@ -772,6 +782,19 @@ impl ConfigRequirementsWithSources { } } +fn normalize_hostname(hostname: &str) -> Option { + let hostname = hostname.trim().trim_end_matches('.'); + (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase()) +} + +fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool { + patterns.iter().any(|pattern| { + normalize_hostname(pattern) + .map(|pattern| WildMatchPattern::<'*', '?'>::new_case_insensitive(&pattern)) + .is_some_and(|pattern| pattern.matches(hostname)) + }) +} + /// Currently, `external-sandbox` is not supported in config.toml, but it is /// supported through programmatic use. #[derive(Deserialize, Debug, Clone, Copy, PartialEq)] @@ -806,10 +829,27 @@ pub enum ResidencyRequirement { } impl ConfigRequirementsToml { + pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) { + let Some(hostname) = hostname.and_then(normalize_hostname) else { + return; + }; + let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else { + return; + }; + let Some(matched_config) = remote_sandbox_config + .iter() + .find(|config| hostname_matches_any_pattern(&hostname, &config.hostname_patterns)) + else { + return; + }; + self.allowed_sandbox_modes = Some(matched_config.allowed_sandbox_modes.clone()); + } + pub fn is_empty(&self) -> bool { self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() + && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self .feature_requirements @@ -1099,6 +1139,7 @@ mod tests { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + remote_sandbox_config: _, allowed_web_search_modes, feature_requirements, mcp_servers, @@ -1161,6 +1202,7 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), feature_requirements: Some(feature_requirements.clone()), mcp_servers: None, @@ -1883,6 +1925,172 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn deserialize_remote_sandbox_config_requires_hostname_patterns_list() -> Result<()> { + let toml_str = r#" + [[remote_sandbox_config]] + hostname_patterns = ["*.org", "runner-??.ci"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + config.remote_sandbox_config, + Some(vec![RemoteSandboxConfigToml { + hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()], + allowed_sandbox_modes: vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ], + }]) + ); + + let err = from_str::( + r#" + [[remote_sandbox_config]] + hostname_patterns = "*.org" + allowed_sandbox_modes = ["read-only"] + "#, + ) + .expect_err("hostname_patterns should be list-only"); + assert!( + err.to_string().contains("invalid type: string"), + "unexpected error: {err}" + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { + let source = RequirementSource::CloudRequirements; + let mut requirements_toml: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-*.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-01.example.com"] + allowed_sandbox_modes = ["read-only", "danger-full-access"] + "#, + )?; + requirements_toml.apply_remote_sandbox_config(Some("BUILD-01.EXAMPLE.COM.")); + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), requirements_toml); + + assert_eq!( + requirements_with_sources + .allowed_sandbox_modes + .as_ref() + .map(|sourced| sourced.value.clone()), + Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ]) + ); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + assert!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }) + .is_ok() + ); + assert_eq!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::DangerFullAccess), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: source, + }) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_non_match_preserves_top_level() -> Result<()> { + let mut requirements_toml: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-*.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + requirements_toml.apply_remote_sandbox_config(Some("laptop.example.com")); + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, requirements_toml); + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + + assert_eq!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::DangerFullAccess), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { + let high_source = RequirementSource::CloudRequirements; + let mut high_precedence: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + "#, + )?; + high_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com")); + + let mut low_precedence: ConfigRequirementsToml = from_str( + r#" + [[remote_sandbox_config]] + hostname_patterns = ["runner-*.ci.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + low_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com")); + + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(high_source.clone(), high_precedence); + requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, low_precedence); + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + + assert_eq!( + requirements + .sandbox_policy + .can_set(&SandboxPolicy::new_workspace_write_policy()), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".into(), + allowed: "[ReadOnly]".into(), + requirement_source: high_source, + }) + ); + + Ok(()) + } + #[test] fn deserialize_allowed_web_search_modes() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/host_name.rs b/codex-rs/config/src/host_name.rs new file mode 100644 index 000000000..dcd34b0ba --- /dev/null +++ b/codex-rs/config/src/host_name.rs @@ -0,0 +1,97 @@ +#[cfg(unix)] +use dns_lookup::AddrInfoHints; +#[cfg(unix)] +use dns_lookup::getaddrinfo; +use std::sync::LazyLock; +#[cfg(windows)] +use winapi_util::sysinfo::ComputerNameKind; +#[cfg(windows)] +use winapi_util::sysinfo::get_computer_name; + +static HOST_NAME: LazyLock> = LazyLock::new(compute_host_name); + +pub fn host_name() -> Option { + HOST_NAME.clone() +} + +fn compute_host_name() -> Option { + let kernel_hostname = gethostname::gethostname(); + let kernel_hostname = normalize_host_name(&kernel_hostname.to_string_lossy())?; + + // Remote sandbox requirements are meant to target remote hosts by DNS name, + // so prefer the canonical FQDN when the local resolver can provide one. + // This is best-effort host classification, not authenticated device proof. + if let Some(fqdn) = local_fqdn_for_hostname(&kernel_hostname) { + return Some(fqdn); + } + + // Some machines have only a short local hostname or resolver setup that + // does not return AI_CANONNAME. Keep matching behavior best-effort by + // falling back to the cleaned kernel hostname instead of returning None. + Some(kernel_hostname) +} + +fn normalize_host_name(hostname: &str) -> Option { + let hostname = hostname.trim().trim_end_matches('.'); + (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase()) +} + +#[cfg(unix)] +fn local_fqdn_for_hostname(hostname: &str) -> Option { + let hints = AddrInfoHints { + flags: libc::AI_CANONNAME, + ..AddrInfoHints::default() + }; + + getaddrinfo(Some(hostname), /*service*/ None, Some(hints)) + .ok()? + .filter_map(Result::ok) + .filter_map(|addr| addr.canonname) + // getaddrinfo may return the short hostname as canonname when no FQDN + // is available. Treat only DNS-qualified names as an FQDN result. + .find_map(|hostname| normalize_fqdn_candidate(&hostname)) +} + +#[cfg(windows)] +fn local_fqdn_for_hostname(_hostname: &str) -> Option { + get_computer_name(ComputerNameKind::PhysicalDnsFullyQualified) + .ok() + .and_then(|hostname| hostname.into_string().ok()) + .and_then(|hostname| normalize_fqdn_candidate(&hostname)) +} + +#[cfg(not(any(unix, windows)))] +fn local_fqdn_for_hostname(_hostname: &str) -> Option { + None +} + +fn normalize_fqdn_candidate(hostname: &str) -> Option { + normalize_host_name(hostname).filter(|hostname| hostname.contains('.')) +} + +#[cfg(test)] +mod tests { + use super::normalize_fqdn_candidate; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_fqdn_candidate_accepts_dns_qualified_name() { + assert_eq!( + normalize_fqdn_candidate("runner-01.ci.example.com"), + Some("runner-01.ci.example.com".to_string()) + ); + } + + #[test] + fn normalize_fqdn_candidate_rejects_short_name() { + assert_eq!(normalize_fqdn_candidate("runner-01"), None); + } + + #[test] + fn normalize_fqdn_candidate_trims_trailing_dot_and_normalizes_case() { + assert_eq!( + normalize_fqdn_candidate("RUNNER-01.CI.EXAMPLE.COM."), + Some("runner-01.ci.example.com".to_string()) + ); + } +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 3fc7e3ed3..e70550f00 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -4,6 +4,7 @@ pub mod config_toml; mod constraint; mod diagnostics; mod fingerprint; +mod host_name; mod key_aliases; mod marketplace_edit; mod mcp_edit; @@ -43,6 +44,7 @@ pub use config_requirements::NetworkDomainPermissionsToml; pub use config_requirements::NetworkRequirementsToml; pub use config_requirements::NetworkUnixSocketPermissionToml; pub use config_requirements::NetworkUnixSocketPermissionsToml; +pub use config_requirements::RemoteSandboxConfigToml; pub use config_requirements::RequirementSource; pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; @@ -63,6 +65,7 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; +pub use host_name::host_name; pub use marketplace_edit::MarketplaceConfigUpdate; pub use marketplace_edit::RemoveMarketplaceConfigOutcome; pub use marketplace_edit::record_user_marketplace; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index fb48e7316..e7c7aa31d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2213,6 +2213,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; let cfg = @@ -2348,6 +2349,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -5484,6 +5486,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: Some(vec![ crate::config_loader::WebSearchModeRequirement::Cached, ]), @@ -6160,6 +6163,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]), + remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2a4171acf..a6240fdcb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -649,7 +649,7 @@ impl AuthManagerConfig for Config { } } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct ConfigBuilder { codex_home: Option, cli_overrides: Option>, @@ -658,6 +658,22 @@ pub struct ConfigBuilder { cloud_requirements: CloudRequirementsLoader, thread_config_loader: Option>, fallback_cwd: Option, + host_name: Option, +} + +impl Default for ConfigBuilder { + fn default() -> Self { + Self { + codex_home: None, + cli_overrides: None, + harness_overrides: None, + loader_overrides: None, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: None, + fallback_cwd: None, + host_name: codex_config::host_name(), + } + } } impl ConfigBuilder { @@ -699,6 +715,11 @@ impl ConfigBuilder { self } + pub fn host_name(mut self, host_name: Option) -> Self { + self.host_name = host_name; + self + } + pub async fn build(self) -> std::io::Result { let Self { codex_home, @@ -708,6 +729,7 @@ impl ConfigBuilder { cloud_requirements, thread_config_loader, fallback_cwd, + host_name, } = self; let codex_home = match codex_home { Some(codex_home) => AbsolutePathBuf::from_absolute_path(codex_home)?, @@ -732,6 +754,7 @@ impl ConfigBuilder { thread_config_loader .as_deref() .unwrap_or(&codex_config::NoopThreadConfigLoader), + host_name.as_deref(), ) .await?; let merged_toml = config_layer_stack.effective_config(); @@ -911,6 +934,7 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides( loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1083,6 +1107,7 @@ pub async fn load_global_mcp_servers( LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; let merged_toml = config_layer_stack.effective_config(); diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index c68091e8e..e9546ef4d 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -115,6 +115,7 @@ pub struct ConfigService { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, + host_name: Option, } impl ConfigService { @@ -123,12 +124,14 @@ impl ConfigService { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, + host_name: Option, ) -> Self { Self { codex_home, cli_overrides, loader_overrides, cloud_requirements, + host_name, } } @@ -138,6 +141,7 @@ impl ConfigService { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), + host_name: codex_config::host_name(), } } @@ -148,6 +152,7 @@ impl ConfigService { Vec::new(), LoaderOverrides::without_managed_config_for_tests(), CloudRequirementsLoader::default(), + /*host_name*/ None, ) } @@ -166,6 +171,7 @@ impl ConfigService { .loader_overrides(self.loader_overrides.clone()) .fallback_cwd(Some(cwd.to_path_buf())) .cloud_requirements(self.cloud_requirements.clone()) + .host_name(self.host_name.clone()) .build() .await .map_err(|err| { @@ -432,6 +438,7 @@ impl ConfigService { self.loader_overrides.clone(), self.cloud_requirements.clone(), &codex_config::NoopThreadConfigLoader, + self.host_name.as_deref(), ) .await } diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index 34378fe14..ceb801c45 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -223,6 +223,7 @@ async fn read_includes_origins_and_layers() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), + /*host_name*/ None, ); let response = service @@ -301,6 +302,7 @@ writable_roots = ["~/code"] vec![], loader_overrides, CloudRequirementsLoader::default(), + /*host_name*/ None, ); let response = service @@ -341,6 +343,7 @@ async fn write_value_reports_override() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), + /*host_name*/ None, ); let result = service @@ -440,6 +443,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), + /*host_name*/ None, ); let error = service @@ -507,6 +511,7 @@ async fn write_value_rejects_feature_requirement_conflict() { ..Default::default() })) }), + /*host_name*/ None, ); let error = service @@ -553,6 +558,7 @@ async fn write_value_rejects_profile_feature_requirement_conflict() { ..Default::default() })) }), + /*host_name*/ None, ); let error = service @@ -603,6 +609,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() { cli_overrides, LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), + /*host_name*/ None, ); let response = service @@ -656,6 +663,7 @@ async fn write_value_reports_managed_override() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), + /*host_name*/ None, ); let result = service diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index 0d4e7d6b8..6ee445421 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_core::config_loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader, host_name) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -41,9 +41,7 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_core::config_loader::{ - CloudRequirementsLoader, LoaderOverrides, load_config_layers_state, -}; +use codex_core::config_loader::{CloudRequirementsLoader, LoaderOverrides, load_config_layers_state}; use codex_config::NoopThreadConfigLoader; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; @@ -59,6 +57,7 @@ let layers = load_config_layers_state( LoaderOverrides::default(), CloudRequirementsLoader::default(), &NoopThreadConfigLoader, + /*host_name*/ None, ).await?; let effective = layers.effective_config(); diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/core/src/config_loader/macos.rs index d94b1930b..977a09a9c 100644 --- a/codex-rs/core/src/config_loader/macos.rs +++ b/codex-rs/core/src/config_loader/macos.rs @@ -1,6 +1,7 @@ use super::ConfigRequirementsToml; use super::ConfigRequirementsWithSources; use super::RequirementSource; +use super::merge_requirements_with_remote_sandbox_config; use base64::Engine; use base64::prelude::BASE64_STANDARD; use core_foundation::base::TCFType; @@ -64,6 +65,7 @@ fn load_managed_admin_config() -> io::Result> { pub(crate) async fn load_managed_admin_requirements_toml( target: &mut ConfigRequirementsWithSources, override_base64: Option<&str>, + host_name: Option<&str>, ) -> io::Result<()> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); @@ -71,9 +73,11 @@ pub(crate) async fn load_managed_admin_requirements_toml( return Ok(()); } - target.merge_unset_fields( + merge_requirements_with_remote_sandbox_config( + target, managed_preferences_requirements_source(), parse_managed_requirements_base64(trimmed)?, + host_name, ); return Ok(()); } @@ -81,7 +85,12 @@ pub(crate) async fn load_managed_admin_requirements_toml( match task::spawn_blocking(load_managed_admin_requirements).await { Ok(result) => { if let Some(requirements) = result? { - target.merge_unset_fields(managed_preferences_requirements_source(), requirements); + merge_requirements_with_remote_sandbox_config( + target, + managed_preferences_requirements_source(), + requirements, + host_name, + ); } Ok(()) } diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index c72cb4cff..3b08de32a 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -54,6 +54,7 @@ pub use codex_config::NetworkDomainPermissionsToml; pub use codex_config::NetworkRequirementsToml; pub use codex_config::NetworkUnixSocketPermissionToml; pub use codex_config::NetworkUnixSocketPermissionsToml; +pub use codex_config::RemoteSandboxConfigToml; pub use codex_config::RequirementSource; pub use codex_config::ResidencyRequirement; pub use codex_config::SandboxModeRequirement; @@ -122,6 +123,7 @@ pub(crate) async fn first_layer_config_error_from_entries( /// associated with it such that `cwd` should be `Some(...)`. Only for /// thread-agnostic config loading (e.g., for the app server's `/config` /// endpoint) should `cwd` be `None`. +#[allow(clippy::too_many_arguments)] pub async fn load_config_layers_state( fs: &dyn ExecutorFileSystem, codex_home: &Path, @@ -130,6 +132,7 @@ pub async fn load_config_layers_state( overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, + host_name: Option<&str>, ) -> io::Result { let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -137,8 +140,12 @@ pub async fn load_config_layers_state( let mut config_requirements_toml = ConfigRequirementsWithSources::default(); if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { - config_requirements_toml - .merge_unset_fields(RequirementSource::CloudRequirements, requirements); + merge_requirements_with_remote_sandbox_config( + &mut config_requirements_toml, + RequirementSource::CloudRequirements, + requirements, + host_name, + ); } #[cfg(target_os = "macos")] @@ -147,12 +154,19 @@ pub async fn load_config_layers_state( overrides .macos_managed_config_requirements_base64 .as_deref(), + host_name, ) .await?; // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file()?; - load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; + load_requirements_toml( + fs, + &mut config_requirements_toml, + &requirements_toml_file, + host_name, + ) + .await?; // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. @@ -161,6 +175,7 @@ pub async fn load_config_layers_state( load_requirements_from_legacy_scheme( &mut config_requirements_toml, loaded_config_layers.clone(), + host_name, ) .await?; @@ -413,6 +428,7 @@ async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, + host_name: Option<&str>, ) -> io::Result<()> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) @@ -439,11 +455,13 @@ async fn load_requirements_toml( ), ) })?; - config_requirements_toml.merge_unset_fields( + merge_requirements_with_remote_sandbox_config( + config_requirements_toml, RequirementSource::SystemRequirementsToml { file: requirements_toml_file.clone(), }, requirements_config, + host_name, ); } Err(e) => { @@ -558,6 +576,7 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { async fn load_requirements_from_legacy_scheme( config_requirements_toml: &mut ConfigRequirementsWithSources, loaded_config_layers: LoadedConfigLayers, + host_name: Option<&str>, ) -> io::Result<()> { // In this implementation, earlier layers cannot be overwritten by later // layers, so list managed_config_from_mdm first because it has the highest @@ -590,13 +609,27 @@ async fn load_requirements_from_legacy_scheme( ) })?; - let new_requirements_toml = ConfigRequirementsToml::from(legacy_config); - config_requirements_toml.merge_unset_fields(source, new_requirements_toml); + merge_requirements_with_remote_sandbox_config( + config_requirements_toml, + source, + ConfigRequirementsToml::from(legacy_config), + host_name, + ); } Ok(()) } +pub(super) fn merge_requirements_with_remote_sandbox_config( + target: &mut ConfigRequirementsWithSources, + source: RequirementSource, + mut requirements: ConfigRequirementsToml, + host_name: Option<&str>, +) { + requirements.apply_remote_sandbox_config(host_name); + target.merge_unset_fields(source, requirements); +} + struct ProjectTrustContext { project_root: AbsolutePathBuf, project_root_key: String, diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 17f54be06..337960f1b 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -24,7 +24,6 @@ use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; 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; @@ -104,6 +103,7 @@ async fn returns_config_error_for_invalid_user_config_toml() { LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await .expect_err("expected error"); @@ -136,6 +136,7 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { }, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -167,6 +168,7 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: }, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -192,6 +194,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await .expect_err("expected error"); @@ -278,6 +281,7 @@ extra = true overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await .expect("load config"); @@ -312,6 +316,7 @@ async fn returns_empty_when_all_layers_missing() { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await .expect("load layers"); @@ -380,6 +385,7 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { features: BTreeMap::from([("plugins".to_string(), false)]), ..Default::default() })]), + /*host_name*/ None, ) .await?; @@ -456,6 +462,7 @@ flag = false overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await .expect("load config"); @@ -559,6 +566,7 @@ allowed_sandbox_modes = ["read-only"] loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -622,6 +630,7 @@ allowed_approval_policies = ["never"] loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -663,6 +672,7 @@ personality = true LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, + /*host_name*/ None, ) .await?; @@ -764,6 +774,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, @@ -776,6 +787,7 @@ allowed_approval_policies = ["on-request"] })) }), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -818,6 +830,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, @@ -833,6 +846,7 @@ allowed_approval_policies = ["on-request"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, + /*host_name*/ None, ) .await?; @@ -854,6 +868,54 @@ allowed_approval_policies = ["on-request"] Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn system_remote_sandbox_config_keeps_cloud_sandbox_modes() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +[[remote_sandbox_config]] +hostname_patterns = ["runner-*.ci.example.com"] +allowed_sandbox_modes = ["read-only", "workspace-write"] +"#, + ) + .await?; + + let cloud_source = RequirementSource::CloudRequirements; + let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + config_requirements_toml.merge_unset_fields( + cloud_source.clone(), + toml::from_str( + r#" +allowed_sandbox_modes = ["read-only"] +"#, + )?, + ); + load_requirements_toml( + LOCAL_FS.as_ref(), + &mut config_requirements_toml, + &AbsolutePathBuf::try_from(requirements_file)?, + Some("runner-01.ci.example.com"), + ) + .await?; + let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; + + assert_eq!( + config_requirements + .sandbox_policy + .can_set(&SandboxPolicy::new_workspace_write_policy()), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".into(), + allowed: "[ReadOnly]".into(), + requirement_source: cloud_source, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn load_requirements_toml_resolves_deny_read_against_parent() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -875,6 +937,7 @@ deny_read = ["./sensitive", "../shared/secret.txt"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, + /*host_name*/ None, ) .await?; @@ -929,6 +992,7 @@ deny_read = ["./sensitive/**/*.txt"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, + /*host_name*/ None, ) .await?; @@ -974,6 +1038,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, mcp_servers: None, @@ -995,6 +1060,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> LoaderOverrides::default(), cloud_requirements, &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1018,6 +1084,53 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> Ok(()) } +#[tokio::test] +async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements: ConfigRequirementsToml = toml::from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["runner-*.ci.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + cloud_requirements, + &codex_config::NoopThreadConfigLoader, + Some("runner-01.ci.example.com"), + ) + .await?; + + assert_eq!( + layers.requirements_toml().allowed_sandbox_modes, + Some(vec![ + crate::config_loader::SandboxModeRequirement::ReadOnly, + crate::config_loader::SandboxModeRequirement::WorkspaceWrite, + ]) + ); + assert!( + layers + .requirements() + .sandbox_policy + .can_set(&SandboxPolicy::new_workspace_write_policy()) + .is_ok() + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1039,6 +1152,7 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh )) }), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await .expect_err("cloud requirements failure should fail closed"); @@ -1087,6 +1201,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1233,6 +1348,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1274,6 +1390,7 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1332,6 +1449,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1404,6 +1522,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; let project_layers_untrusted: Vec<_> = layers_untrusted @@ -1444,6 +1563,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; let project_layers_unknown: Vec<_> = layers_unknown @@ -1511,6 +1631,7 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1665,6 +1786,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers @@ -1734,6 +1856,7 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers @@ -1795,6 +1918,7 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; @@ -1839,6 +1963,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await?; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 8829b45c3..78428fabf 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -54,6 +54,7 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec, for LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, + /*host_name*/ None, ) .await { diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index fbaf9f2a1..6cb71792f 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -640,6 +640,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), + remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), guardian_policy_config: None, feature_requirements: Some(FeatureRequirementsToml { @@ -843,6 +844,7 @@ approval_policy = "never" allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), guardian_policy_config: None, feature_requirements: None,