From aee0f23c6ba3977009d385bd0cfcad960608b007 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Mar 2026 09:10:06 -0800 Subject: [PATCH] feat: gate network proxy behind experimental feature --- codex-rs/config/src/config_requirements.rs | 24 ++-- codex-rs/core/config.schema.json | 115 +++++++++--------- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/config/mod.rs | 103 ++++++++++++++-- .../core/src/config/network_proxy_spec.rs | 9 +- codex-rs/core/src/config/permissions.rs | 70 ++++++++++- codex-rs/core/src/config_loader/macos.rs | 11 +- codex-rs/core/src/config_loader/mod.rs | 37 ++++-- codex-rs/core/src/config_loader/tests.rs | 65 ++++++++++ codex-rs/core/src/features.rs | 21 ++++ codex-rs/core/src/network_policy_decision.rs | 2 +- codex-rs/core/src/network_proxy_loader.rs | 35 +++++- codex-rs/core/tests/suite/approvals.rs | 3 - 13 files changed, 389 insertions(+), 108 deletions(-) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9f4719ff16..4c74457c81 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -83,7 +83,7 @@ pub struct ConfigRequirements { pub mcp_servers: Option>>, pub exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, - /// Managed network constraints derived from requirements. + /// Network constraints derived from requirements. pub network: Option>, } @@ -130,8 +130,8 @@ pub struct McpServerRequirement { } #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct NetworkRequirementsToml { - pub enabled: Option, pub http_port: Option, pub socks_port: Option, pub allow_upstream_proxy: Option, @@ -146,7 +146,6 @@ pub struct NetworkRequirementsToml { /// Normalized network constraints derived from requirements TOML. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct NetworkConstraints { - pub enabled: Option, pub http_port: Option, pub socks_port: Option, pub allow_upstream_proxy: Option, @@ -161,7 +160,6 @@ pub struct NetworkConstraints { impl From for NetworkConstraints { fn from(value: NetworkRequirementsToml) -> Self { let NetworkRequirementsToml { - enabled, http_port, socks_port, allow_upstream_proxy, @@ -173,7 +171,6 @@ impl From for NetworkConstraints { allow_local_binding, } = value; Self { - enabled, http_port, socks_port, allow_upstream_proxy, @@ -1114,7 +1111,6 @@ mod tests { fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { let toml_str = r#" [experimental_network] - enabled = true allow_upstream_proxy = false dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] @@ -1133,7 +1129,6 @@ mod tests { .expect("network requirements should be preserved as constraints"); assert_eq!(sourced_network.source, source); - assert_eq!(sourced_network.value.enabled, Some(true)); assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); assert_eq!( sourced_network.value.dangerously_allow_all_unix_sockets, @@ -1159,6 +1154,21 @@ mod tests { Ok(()) } + #[test] + fn network_requirements_reject_enabled_flag() { + let toml_str = r#" + [experimental_network] + enabled = true + "#; + + let err = + from_str::(toml_str).expect_err("enabled flag should fail"); + assert!( + err.to_string().contains("unknown field `enabled`"), + "unexpected error: {err}" + ); + } + #[test] fn deserialize_mcp_server_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f94469530..4110d0e609 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -347,6 +347,9 @@ "enable_experimental_windows_sandbox": { "type": "boolean" }, + "enable_network_proxy": { + "type": "boolean" + }, "enable_request_compression": { "type": "boolean" }, @@ -794,60 +797,6 @@ ], "type": "string" }, - "NetworkToml": { - "additionalProperties": false, - "properties": { - "allow_local_binding": { - "type": "boolean" - }, - "allow_unix_sockets": { - "items": { - "type": "string" - }, - "type": "array" - }, - "allow_upstream_proxy": { - "type": "boolean" - }, - "allowed_domains": { - "items": { - "type": "string" - }, - "type": "array" - }, - "dangerously_allow_all_unix_sockets": { - "type": "boolean" - }, - "dangerously_allow_non_loopback_proxy": { - "type": "boolean" - }, - "denied_domains": { - "items": { - "type": "string" - }, - "type": "array" - }, - "enable_socks5": { - "type": "boolean" - }, - "enable_socks5_udp": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "mode": { - "$ref": "#/definitions/NetworkModeSchema" - }, - "proxy_url": { - "type": "string" - }, - "socks_url": { - "type": "string" - } - }, - "type": "object" - }, "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { @@ -1089,16 +1038,67 @@ }, "type": "object" }, + "PermissionsNetworkToml": { + "additionalProperties": false, + "properties": { + "allow_local_binding": { + "type": "boolean" + }, + "allow_unix_sockets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "allow_upstream_proxy": { + "type": "boolean" + }, + "allowed_domains": { + "items": { + "type": "string" + }, + "type": "array" + }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, + "dangerously_allow_non_loopback_proxy": { + "type": "boolean" + }, + "denied_domains": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enable_socks5": { + "type": "boolean" + }, + "enable_socks5_udp": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/NetworkModeSchema" + }, + "proxy_url": { + "type": "string" + }, + "socks_url": { + "type": "string" + } + }, + "type": "object" + }, "PermissionsToml": { "additionalProperties": false, "properties": { "network": { "allOf": [ { - "$ref": "#/definitions/NetworkToml" + "$ref": "#/definitions/PermissionsNetworkToml" } ], - "description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values." + "description": "Network proxy settings from `[permissions.network]`. Applies when the `enable_network_proxy` feature or managed requirements start the proxy." } }, "type": "object" @@ -1735,6 +1735,9 @@ "enable_experimental_windows_sandbox": { "type": "boolean" }, + "enable_network_proxy": { + "type": "boolean" + }, "enable_request_compression": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7445b9fe0b..0f51a99537 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1012,7 +1012,7 @@ impl Session { audit_metadata, ) .await - .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?; + .map_err(|err| anyhow::anyhow!("failed to start network proxy: {err}"))?; let session_network_proxy = { let proxy = network_proxy.proxy(); SessionNetworkProxyRuntime { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2898faeccb..4136e48d1a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -110,6 +110,7 @@ pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub use permissions::NetworkToml; +pub use permissions::PermissionsNetworkToml; pub use permissions::PermissionsToml; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -1750,7 +1751,7 @@ impl Config { .clone(), None => ConfigProfile::default(), }; - let configured_network_proxy_config = + let mut configured_network_proxy_config = network_proxy_config_from_permissions(cfg.permissions.as_ref()); let feature_overrides = FeatureOverrides { @@ -1760,6 +1761,10 @@ impl Config { let configured_features = Features::from_config(&cfg, &config_profile, feature_overrides); let features = ManagedFeatures::from_configured(configured_features, feature_requirements)?; + let enable_network_proxy = features.enabled(Feature::EnableNetworkProxy); + if enable_network_proxy { + configured_network_proxy_config.network.enabled = true; + } let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let resolved_cwd = normalize_for_native_workdir({ use std::env; @@ -2108,16 +2113,16 @@ impl Config { if let Some(source) = network_requirements_source.as_ref() { std::io::Error::new( err.kind(), - format!("failed to build managed network proxy from {source}: {err}"), + format!("failed to build network proxy from {source}: {err}"), ) } else { err } })?; - let network = if has_network_requirements { + let network = if has_network_requirements || enable_network_proxy { Some(network) } else { - network.enabled().then_some(network) + None }; let config = Self { @@ -2695,7 +2700,6 @@ consolidation_model = "gpt-5" fn config_toml_deserializes_permissions_network() { let toml = r#" [permissions.network] -enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false @@ -2708,8 +2712,7 @@ allowed_domains = ["openai.com"] cfg.permissions .and_then(|permissions| permissions.network) .expect("permissions.network should deserialize"), - NetworkToml { - enabled: Some(true), + PermissionsNetworkToml { proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), socks_url: None, @@ -2727,12 +2730,56 @@ allowed_domains = ["openai.com"] } #[test] - fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> { + fn config_toml_rejects_permissions_network_enabled_flag() { + let toml = r#" +[permissions.network] +enabled = true +"#; + let err = toml::from_str::(toml) + .expect_err("permissions.network.enabled should be rejected"); + assert!( + err.to_string().contains("unknown field `enabled`"), + "unexpected error: {err}" + ); + } + + #[test] + fn enable_network_proxy_feature_populates_runtime_network_proxy_spec_with_defaults() + -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { + features: Some(FeaturesToml { + entries: BTreeMap::from([("enable_network_proxy".to_string(), true)]), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + let network = config + .permissions + .network + .as_ref() + .expect("enable_network_proxy should produce a NetworkProxySpec"); + + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128"); + assert!(network.socks_enabled()); + Ok(()) + } + + #[test] + fn permissions_network_populates_runtime_network_proxy_spec_when_feature_enabled() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + features: Some(FeaturesToml { + entries: BTreeMap::from([("enable_network_proxy".to_string(), true)]), + }), permissions: Some(PermissionsToml { - network: Some(NetworkToml { - enabled: Some(true), + network: Some(PermissionsNetworkToml { proxy_url: Some("http://127.0.0.1:43128".to_string()), enable_socks5: Some(false), ..Default::default() @@ -2750,7 +2797,7 @@ allowed_domains = ["openai.com"] .permissions .network .as_ref() - .expect("enabled permissions.network should produce a NetworkProxySpec"); + .expect("enable_network_proxy should produce a NetworkProxySpec"); assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); assert!(!network.socks_enabled()); @@ -2758,11 +2805,11 @@ allowed_domains = ["openai.com"] } #[test] - fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { + fn permissions_network_without_feature_flag_does_not_start_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { permissions: Some(PermissionsToml { - network: Some(NetworkToml { + network: Some(PermissionsNetworkToml { allowed_domains: Some(vec!["openai.com".to_string()]), ..Default::default() }), @@ -6299,6 +6346,36 @@ mcp_oauth_callback_url = "https://example.com/callback" Ok(()) } + #[tokio::test] + async fn network_requirements_start_proxy_without_enabled_flag() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements = crate::config_loader::ConfigRequirementsToml { + network: Some(crate::config_loader::NetworkRequirementsToml { + allow_local_binding: Some(true), + ..Default::default() + }), + ..Default::default() + }; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + + assert!(config.managed_network_requirements_enabled()); + let network = config + .permissions + .network + .as_ref() + .expect("network requirements should produce a managed network proxy"); + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128"); + + Ok(()) + } + #[tokio::test] async fn requirements_disallowing_default_approval_falls_back_to_required_default() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 671593dca4..1339c2649f 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -68,10 +68,6 @@ impl ConfigReloader for StaticNetworkProxyReloader { } impl NetworkProxySpec { - pub(crate) fn enabled(&self) -> bool { - self.config.network.enabled - } - pub fn proxy_host_and_port(&self) -> String { host_and_port_from_network_addr(&self.config.network.proxy_url, 3128) } @@ -161,10 +157,7 @@ impl NetworkProxySpec { ) -> (NetworkProxyConfig, NetworkProxyConstraints) { let mut constraints = NetworkProxyConstraints::default(); - if let Some(enabled) = requirements.enabled { - config.network.enabled = enabled; - constraints.enabled = Some(enabled); - } + config.network.enabled = true; if let Some(http_port) = requirements.http_port { config.network.proxy_url = format!("http://127.0.0.1:{http_port}"); } diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 15ecc065b1..07ff12b784 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -8,8 +8,27 @@ use serde::Serialize; #[schemars(deny_unknown_fields)] pub struct PermissionsToml { /// Network proxy settings from `[permissions.network]`. - /// User config can enable the proxy; managed requirements may still constrain values. - pub network: Option, + /// Applies when the `enable_network_proxy` feature or requirements start the proxy. + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct PermissionsNetworkToml { + pub proxy_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + #[schemars(with = "Option")] + pub mode: Option, + pub allowed_domains: Option>, + pub denied_domains: Option>, + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -83,6 +102,51 @@ impl NetworkToml { config.network.allow_local_binding = allow_local_binding; } } +} + +impl PermissionsNetworkToml { + pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { + config.network.enabled = true; + if let Some(proxy_url) = self.proxy_url.as_ref() { + config.network.proxy_url = proxy_url.clone(); + } + if let Some(enable_socks5) = self.enable_socks5 { + config.network.enable_socks5 = enable_socks5; + } + if let Some(socks_url) = self.socks_url.as_ref() { + config.network.socks_url = socks_url.clone(); + } + if let Some(enable_socks5_udp) = self.enable_socks5_udp { + config.network.enable_socks5_udp = enable_socks5_udp; + } + if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + } + if let Some(dangerously_allow_non_loopback_proxy) = + self.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } + if let Some(mode) = self.mode { + config.network.mode = mode; + } + if let Some(allowed_domains) = self.allowed_domains.as_ref() { + config.network.allowed_domains = allowed_domains.clone(); + } + if let Some(denied_domains) = self.denied_domains.as_ref() { + config.network.denied_domains = denied_domains.clone(); + } + if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() { + config.network.allow_unix_sockets = allow_unix_sockets.clone(); + } + if let Some(allow_local_binding) = self.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + } + } pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig { let mut config = NetworkProxyConfig::default(); @@ -98,6 +162,6 @@ pub(crate) fn network_proxy_config_from_permissions( .and_then(|permissions| permissions.network.as_ref()) .map_or_else( NetworkProxyConfig::default, - NetworkToml::to_network_proxy_config, + PermissionsNetworkToml::to_network_proxy_config, ) } diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/core/src/config_loader/macos.rs index d94b1930b7..2e62bda3fa 100644 --- a/codex-rs/core/src/config_loader/macos.rs +++ b/codex-rs/core/src/config_loader/macos.rs @@ -150,12 +150,13 @@ fn parse_managed_config_base64(encoded: &str) -> io::Result io::Result { - toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( - |err| { - tracing::error!("Failed to parse managed requirements TOML: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - }, + super::parse_requirements_toml_with_legacy_network_enabled_compat( + &decode_managed_preferences_base64(encoded)?, ) + .map_err(|err| { + tracing::error!("Failed to parse managed requirements TOML: {err}"); + io::Error::new(io::ErrorKind::InvalidData, err) + }) } fn decode_managed_preferences_base64(encoded: &str) -> io::Result { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index b94bf81d08..4f4f03d95c 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -351,16 +351,18 @@ async fn load_requirements_toml( AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?; match tokio::fs::read_to_string(&requirements_toml_file).await { Ok(contents) => { - let requirements_config: ConfigRequirementsToml = - toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing requirements file {}: {e}", - requirements_toml_file.as_ref().display(), - ), - ) - })?; + let requirements_config = parse_requirements_toml_with_legacy_network_enabled_compat( + &contents, + ) + .map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing requirements file {}: {e}", + requirements_toml_file.as_ref().display(), + ), + ) + })?; config_requirements_toml.merge_unset_fields( RequirementSource::SystemRequirementsToml { file: requirements_toml_file.clone(), @@ -384,6 +386,21 @@ async fn load_requirements_toml( Ok(()) } +fn parse_requirements_toml_with_legacy_network_enabled_compat( + contents: &str, +) -> Result { + let mut raw_requirements: TomlValue = toml::from_str(contents)?; + if let Some(network_table) = raw_requirements + .as_table_mut() + .and_then(|table| table.get_mut("experimental_network")) + .and_then(TomlValue::as_table_mut) + && network_table.remove("enabled").is_some() + { + tracing::warn!("Ignoring legacy experimental_network.enabled in requirements input"); + } + raw_requirements.try_into() +} + #[cfg(unix)] fn system_requirements_toml_file() -> io::Result { AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml")) diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index fb8de08615..cd63dc4145 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -440,6 +440,45 @@ allowed_sandbox_modes = ["read-only"] Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_requirements_ignore_legacy_network_enabled() -> anyhow::Result<()> { + use base64::Engine; + + let tmp = tempdir()?; + + let state = load_config_layers_state( + tmp.path(), + Some(AbsolutePathBuf::try_from(tmp.path())?), + &[] as &[(String, TomlValue)], + LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +[experimental_network] +enabled = true +allow_local_binding = true +"# + .as_bytes(), + ), + ), + }, + CloudRequirementsLoader::default(), + ) + .await?; + + let network = state + .requirements() + .network + .as_ref() + .expect("network requirements"); + assert_eq!(network.value.allow_local_binding, Some(true)); + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> { @@ -633,6 +672,32 @@ allowed_approval_policies = ["on-request"] Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn load_requirements_toml_ignores_legacy_network_enabled() -> anyhow::Result<()> { + let tmp = tempdir()?; + let requirements_file = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_file, + r#" +[experimental_network] +enabled = true +allow_local_binding = true +"#, + ) + .await?; + + let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?; + + let network = config_requirements_toml + .network + .as_ref() + .expect("network requirements"); + assert_eq!(network.value.allow_local_binding, Some(true)); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7defc571ff..8620d44c40 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -90,6 +90,8 @@ pub enum Feature { ApplyPatchFreeform, /// Allow requesting additional filesystem permissions while staying sandboxed. RequestPermissions, + /// Start the network proxy even without explicit `[permissions.network]` config. + EnableNetworkProxy, /// Allow the model to request web searches that fetch live content. WebSearchRequest, /// Allow the model to request web searches that fetch cached content. @@ -572,6 +574,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::EnableNetworkProxy, + key: "enable_network_proxy", + stage: Stage::Experimental { + name: "Network proxy", + menu_description: "Start Codex's network proxy with default settings even without explicit [permissions.network] config. Use [permissions.network] to override the proxy defaults.", + announcement: "NEW: Network proxy can now be enabled from /experimental. Restart Codex after enabling it.", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::UseLinuxSandboxBwrap, key: "use_linux_sandbox_bwrap", @@ -890,6 +902,15 @@ mod tests { assert_eq!(Feature::ImageGeneration.default_enabled(), false); } + #[test] + fn enable_network_proxy_is_experimental() { + assert!(matches!( + Feature::EnableNetworkProxy.stage(), + Stage::Experimental { .. } + )); + assert_eq!(Feature::EnableNetworkProxy.default_enabled(), false); + } + #[test] fn collab_is_legacy_alias_for_multi_agent() { assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); diff --git a/codex-rs/core/src/network_policy_decision.rs b/codex-rs/core/src/network_policy_decision.rs index e40ae854c4..bdeb475523 100644 --- a/codex-rs/core/src/network_policy_decision.rs +++ b/codex-rs/core/src/network_policy_decision.rs @@ -81,7 +81,7 @@ pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option< "not_allowed" => "domain is not on the allowlist for the current sandbox mode", "not_allowed_local" => "local/private network addresses are blocked by policy", "method_not_allowed" => "request method is blocked by the current network mode", - "proxy_disabled" => "managed network proxy is disabled", + "proxy_disabled" => "network proxy is disabled", _ => "request is blocked by network policy", }; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 5ffd274be6..88be6a3b59 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,4 +1,5 @@ use crate::config::NetworkToml; +use crate::config::PermissionsNetworkToml; use crate::config::PermissionsToml; use crate::config::find_codex_home; use crate::config_loader::CloudRequirementsLoader; @@ -125,7 +126,7 @@ fn network_constraints_from_trusted_layers( .permissions .and_then(|permissions| permissions.network) { - apply_network_constraints(network, &mut constraints); + apply_permissions_network_constraints(network, &mut constraints); } } Ok(constraints) @@ -163,6 +164,38 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx } } +fn apply_permissions_network_constraints( + network: PermissionsNetworkToml, + constraints: &mut NetworkProxyConstraints, +) { + if let Some(mode) = network.mode { + constraints.mode = Some(mode); + } + if let Some(allow_upstream_proxy) = network.allow_upstream_proxy { + constraints.allow_upstream_proxy = Some(allow_upstream_proxy); + } + if let Some(dangerously_allow_non_loopback_proxy) = network.dangerously_allow_non_loopback_proxy + { + constraints.dangerously_allow_non_loopback_proxy = + Some(dangerously_allow_non_loopback_proxy); + } + if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets { + constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); + } + if let Some(allowed_domains) = network.allowed_domains { + constraints.allowed_domains = Some(allowed_domains); + } + if let Some(denied_domains) = network.denied_domains { + constraints.denied_domains = Some(denied_domains); + } + if let Some(allow_unix_sockets) = network.allow_unix_sockets { + constraints.allow_unix_sockets = Some(allow_unix_sockets); + } + if let Some(allow_local_binding) = network.allow_local_binding { + constraints.allow_local_binding = Some(allow_local_binding); + } +} + #[derive(Debug, Clone, Default, Deserialize)] struct NetworkTablesToml { network: Option, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 9ca159965f..622bb74a21 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2235,7 +2235,6 @@ async fn denying_network_policy_amendment_persists_policy_and_skips_future_netwo fs::write( home.path().join("config.toml"), r#"[permissions.network] -enabled = true mode = "limited" allow_local_binding = true "#, @@ -2261,7 +2260,6 @@ allow_local_binding = true let mut requirements = config.config_layer_stack.requirements().clone(); requirements.network = Some(Sourced::new( NetworkConstraints { - enabled: Some(true), allow_local_binding: Some(true), ..Default::default() }, @@ -2269,7 +2267,6 @@ allow_local_binding = true )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(NetworkRequirementsToml { - enabled: Some(true), allow_local_binding: Some(true), ..Default::default() });