diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 715822fbfe..df0de3b1cd 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -43,6 +43,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::NetworkProxy { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } validation .properties .insert(feature.key.to_string(), schema_gen.subschema_for::()); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 99fab110d0..6c1eebeab7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -487,6 +487,9 @@ "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, + "network_proxy": { + "$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml" + }, "personality": { "type": "boolean" }, @@ -802,6 +805,16 @@ } ] }, + "FeatureToml_for_NetworkProxyConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/NetworkProxyConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -1494,6 +1507,75 @@ ], "type": "string" }, + "NetworkProxyConfigToml": { + "additionalProperties": false, + "properties": { + "allow_local_binding": { + "type": "boolean" + }, + "allow_upstream_proxy": { + "type": "boolean" + }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, + "dangerously_allow_non_loopback_proxy": { + "type": "boolean" + }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkProxyDomainPermissionToml" + }, + "type": "object" + }, + "enable_socks5": { + "type": "boolean" + }, + "enable_socks5_udp": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/NetworkProxyModeToml" + }, + "proxy_url": { + "type": "string" + }, + "socks_url": { + "type": "string" + }, + "unix_sockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkProxyUnixSocketPermissionToml" + }, + "type": "object" + } + }, + "type": "object" + }, + "NetworkProxyDomainPermissionToml": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkProxyModeToml": { + "enum": [ + "limited", + "full" + ], + "type": "string" + }, + "NetworkProxyUnixSocketPermissionToml": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NetworkToml": { "additionalProperties": false, "properties": { @@ -4043,6 +4125,9 @@ "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, + "network_proxy": { + "$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml" + }, "personality": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e5cc8c62cc..8ff8df4e83 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -792,59 +792,12 @@ allow_upstream_proxy = false } #[tokio::test] -async fn permissions_profiles_network_enabled_allows_runtime_network_without_proxy() +async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; - let config = Config::load_from_base_config_with_overrides( - ConfigToml { - default_permissions: Some("workspace".to_string()), - permissions: Some(PermissionsToml { - entries: BTreeMap::from([( - "workspace".to_string(), - PermissionProfileToml { - filesystem: Some(FilesystemPermissionsToml { - glob_scan_max_depth: None, - entries: BTreeMap::from([( - ":minimal".to_string(), - FilesystemPermissionToml::Access(FileSystemAccessMode::Read), - )]), - }), - network: Some(NetworkToml { - enabled: Some(true), - ..Default::default() - }), - }, - )]), - }), - ..Default::default() - }, - ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }, - codex_home.abs(), - ) - .await?; - assert_eq!( - config.permissions.network_sandbox_policy(), - NetworkSandboxPolicy::Enabled - ); - assert!( - config.permissions.network.is_none(), - "bare profile network.enabled should not start the managed network proxy" - ); - Ok(()) -} - -#[tokio::test] -async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; - let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("workspace".to_string()), @@ -877,6 +830,227 @@ async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std codex_home.abs(), ) .await?; + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert!( + config.permissions.network.is_none(), + "profile proxy policy should not start the managed network proxy without the feature" + ); + Ok(()) +} + +#[tokio::test] +async fn network_proxy_feature_is_no_op_without_sandbox_network() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(toml::from_str("network_proxy = true").expect("valid features")), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted + ); + assert!( + config.permissions.network.is_none(), + "network_proxy should not start the managed network proxy while network access is off" + ); + Ok(()) +} + +#[tokio::test] +async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> std::io::Result<()> { + #[derive(Clone, Copy)] + enum Surface { + PermissionProfile, + LegacyWorkspaceWrite, + } + + struct Case { + name: &'static str, + surface: Surface, + network_enabled: bool, + proxy_enabled: bool, + expected_network_policy: NetworkSandboxPolicy, + } + + let cases = [ + Case { + name: "permission profile network disabled without proxy", + surface: Surface::PermissionProfile, + network_enabled: false, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "permission profile network disabled with proxy", + surface: Surface::PermissionProfile, + network_enabled: false, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "permission profile network enabled without proxy", + surface: Surface::PermissionProfile, + network_enabled: true, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + Case { + name: "permission profile network enabled with proxy", + surface: Surface::PermissionProfile, + network_enabled: true, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + Case { + name: "legacy workspace write network disabled without proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: false, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "legacy workspace write network disabled with proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: false, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "legacy workspace write network enabled without proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: true, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + Case { + name: "legacy workspace write network enabled with proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: true, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + ]; + + for case in cases { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + let features = case + .proxy_enabled + .then(|| toml::from_str("network_proxy = true").expect("valid features")); + let base_config = match case.surface { + Surface::PermissionProfile => ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(case.network_enabled), + ..Default::default() + }), + }, + )]), + }), + features, + ..Default::default() + }, + Surface::LegacyWorkspaceWrite => ConfigToml { + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + network_access: case.network_enabled, + ..Default::default() + }), + windows: Some(WindowsToml { + sandbox: Some(WindowsSandboxModeToml::Elevated), + sandbox_private_desktop: None, + }), + features, + ..Default::default() + }, + }; + let config = Config::load_from_base_config_with_overrides( + base_config, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.network_sandbox_policy(), + case.expected_network_policy, + "{}", + case.name + ); + assert_eq!( + config.permissions.network.is_some(), + case.network_enabled && case.proxy_enabled, + "{}", + case.name + ); + } + + Ok(()) +} + +#[tokio::test] +async fn network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true + +[windows] +sandbox = "elevated" +"#, + )?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![ + ( + "features.network_proxy.enabled".to_string(), + toml::Value::Boolean(true), + ), + ( + "features.network_proxy.enable_socks5".to_string(), + toml::Value::Boolean(false), + ), + ]) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled @@ -885,11 +1059,187 @@ async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std .permissions .network .as_ref() - .expect("profile proxy policy should start the managed network proxy"); - assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + .expect("network_proxy should start the managed network proxy"); + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128"); + assert!(!network.socks_enabled()); + Ok(()) +} + +#[tokio::test] +async fn experimental_network_requirements_enable_proxy_without_feature() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + network: Some(codex_config::NetworkRequirementsToml { + enabled: Some(true), + ..Default::default() + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(!config.features.enabled(Feature::NetworkProxy)); + assert!(config.managed_network_requirements_enabled()); assert!( - !network.socks_enabled(), - "profile proxy policy should preserve SOCKS config" + config + .permissions + .network + .as_ref() + .expect("experimental_network should configure the managed proxy") + .enabled() + ); + Ok(()) +} + +#[tokio::test] +async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(toml::from_str("network_proxy = true").expect("valid features")), + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + let network = config + .permissions + .network + .as_ref() + .expect("network_proxy should start the managed network proxy"); + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + Ok(()) +} + +#[tokio::test] +async fn profile_network_proxy_disable_ignores_base_feature_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some( + toml::from_str( + r#" +[network_proxy] +enabled = true +proxy_url = "http://127.0.0.1:43128" +"#, + ) + .expect("valid base features"), + ), + profiles: HashMap::from([( + "no_proxy".to_string(), + ConfigProfile { + features: Some( + toml::from_str("network_proxy = false").expect("valid profile features"), + ), + ..Default::default() + }, + )]), + profile: Some("no_proxy".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!(!config.features.enabled(Feature::NetworkProxy)); + assert!(config.permissions.network.is_none()); + Ok(()) +} + +#[tokio::test] +async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some( + toml::from_str( + r#" +[network_proxy] +enabled = false +"#, + ) + .expect("valid features"), + ), + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!(!config.features.enabled(Feature::NetworkProxy)); + assert!( + config.permissions.network.is_none(), + "disabled feature should keep profile proxy policy from starting the managed proxy" ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f965ed5099..b22158012c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -64,6 +64,7 @@ use codex_features::FeatureToml; use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; +use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; use codex_mcp::McpConfig; @@ -112,6 +113,7 @@ use std::path::PathBuf; use std::sync::Arc; use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; +use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; use crate::config::permissions::default_builtin_permission_profile_name; @@ -2026,6 +2028,13 @@ fn apps_mcp_path_override_toml_config( } } +fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> { + match features?.network_proxy.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, @@ -2216,6 +2225,7 @@ impl Config { feature_requirements, &mut startup_warnings, )?; + let enable_network_proxy = features.enabled(Feature::NetworkProxy); let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); @@ -2299,7 +2309,7 @@ impl Config { let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); let ( - configured_network_proxy_config, + mut configured_network_proxy_config, permission_profile, file_system_sandbox_policy, mut active_permission_profile, @@ -2512,6 +2522,22 @@ impl Config { None, ) }; + if enable_network_proxy && permission_profile.network_sandbox_policy().is_enabled() { + if let Some(network_proxy) = network_proxy_toml_config(cfg.features.as_ref()) { + apply_network_proxy_feature_config( + &mut configured_network_proxy_config, + network_proxy, + ); + } + if let Some(network_proxy) = network_proxy_toml_config(config_profile.features.as_ref()) + { + apply_network_proxy_feature_config( + &mut configured_network_proxy_config, + network_proxy, + ); + } + configured_network_proxy_config.network.enabled = true; + } let approval_policy_was_explicit = approval_policy_override.is_some() || config_profile.approval_policy.is_some() || cfg.approval_policy.is_some(); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b51a8973a3..6b6021ad34 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -6,10 +6,19 @@ use std::path::PathBuf; use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::types::SandboxWorkspaceWrite; +use codex_features::NetworkProxyConfigToml; +use codex_features::NetworkProxyDomainPermissionToml; +use codex_features::NetworkProxyModeToml; +use codex_features::NetworkProxyUnixSocketPermissionToml; +use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; #[cfg(test)] use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; @@ -108,35 +117,70 @@ pub(crate) fn network_proxy_config_from_profile_network( NetworkProxyConfig::default, NetworkToml::to_network_proxy_config, ); - // Profile `network.enabled` controls sandbox network access. Do not start a - // managed proxy for that bit alone, but keep the proxy enabled when the - // profile also supplied policy that only the proxy can enforce. - config.network.enabled = network.is_some_and(profile_network_requires_proxy); + // Profile `network.enabled` controls sandbox network access. Profiles may + // provide proxy settings for the feature gate to consume when that network + // access is enabled, but they do not start the managed proxy on their own. + config.network.enabled = false; config } -fn profile_network_requires_proxy(network: &NetworkToml) -> bool { - if network.enabled != Some(true) { - return false; - } - - network.proxy_url.is_some() - || network.enable_socks5 == Some(true) - || network.socks_url.is_some() - || network.enable_socks5_udp == Some(true) - || network.allow_upstream_proxy == Some(true) - || network.dangerously_allow_non_loopback_proxy == Some(true) - || network.dangerously_allow_all_unix_sockets == Some(true) - || network.mode.is_some() - || network +pub(crate) fn apply_network_proxy_feature_config( + config: &mut NetworkProxyConfig, + feature_config: &NetworkProxyConfigToml, +) { + NetworkToml { + enabled: feature_config.enabled, + proxy_url: feature_config.proxy_url.clone(), + enable_socks5: feature_config.enable_socks5, + socks_url: feature_config.socks_url.clone(), + enable_socks5_udp: feature_config.enable_socks5_udp, + allow_upstream_proxy: feature_config.allow_upstream_proxy, + dangerously_allow_non_loopback_proxy: feature_config.dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets: feature_config.dangerously_allow_all_unix_sockets, + mode: feature_config.mode.map(|mode| match mode { + NetworkProxyModeToml::Limited => NetworkMode::Limited, + NetworkProxyModeToml::Full => NetworkMode::Full, + }), + domains: feature_config .domains .as_ref() - .is_some_and(|domains| !domains.is_empty()) - || network - .unix_sockets - .as_ref() - .is_some_and(|unix_sockets| !unix_sockets.is_empty()) - || network.allow_local_binding == Some(true) + .map(|domains| NetworkDomainPermissionsToml { + entries: domains + .iter() + .map(|(pattern, permission)| { + let permission = match permission { + NetworkProxyDomainPermissionToml::Allow => { + NetworkDomainPermissionToml::Allow + } + NetworkProxyDomainPermissionToml::Deny => { + NetworkDomainPermissionToml::Deny + } + }; + (pattern.clone(), permission) + }) + .collect(), + }), + unix_sockets: feature_config.unix_sockets.as_ref().map(|unix_sockets| { + NetworkUnixSocketPermissionsToml { + entries: unix_sockets + .iter() + .map(|(path, permission)| { + let permission = match permission { + NetworkProxyUnixSocketPermissionToml::Allow => { + NetworkUnixSocketPermissionToml::Allow + } + NetworkProxyUnixSocketPermissionToml::None => { + NetworkUnixSocketPermissionToml::None + } + }; + (path.clone(), permission) + }) + .collect(), + } + }), + allow_local_binding: feature_config.allow_local_binding, + } + .apply_to_network_proxy_config(config); } pub(crate) fn resolve_permission_profile<'a>( diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 7dd2bcdefe..6ae9307f02 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -247,7 +247,7 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() { } #[test] -fn profile_network_proxy_config_enables_proxy_for_proxy_policy() { +fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() { let config = network_proxy_config_from_profile_network(Some(&NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), @@ -261,7 +261,7 @@ fn profile_network_proxy_config_enables_proxy_for_proxy_policy() { ..Default::default() })); - assert!(config.network.enabled); + assert!(!config.network.enabled); assert_eq!(config.network.proxy_url, "http://127.0.0.1:43128"); assert!(!config.network.enable_socks5); assert_eq!( diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index c9ad846493..76ed9cdcbf 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; +use core_test_support::managed_network_requirements_loader; use core_test_support::process::process_is_alive; use core_test_support::process::wait_for_pid_file; use core_test_support::process::wait_for_process_exit; @@ -872,13 +873,7 @@ async fn unified_exec_short_lived_network_denial_emits_failed_end_event() -> Res async fn unified_exec_network_denial_test( server: &wiremock::MockServer, ) -> Result<(TestCodex, SandboxPolicy)> { - use codex_config::ConfigLayerStack; - use codex_config::ConfigLayerStackOrdering; use codex_config::Constrained; - use codex_config::NetworkConstraints; - use codex_config::NetworkRequirementsToml; - use codex_config::RequirementSource; - use codex_config::Sourced; use std::sync::Arc; use tempfile::TempDir; @@ -901,46 +896,20 @@ allow_local_binding = true *network_access = true; } let sandbox_policy_for_config = sandbox_policy.clone(); - let mut builder = test_codex().with_home(home).with_config(move |config| { - config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::UnifiedExec) - .expect("test config should allow feature update"); - config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); - config.permissions.permission_profile = Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy_for_config), - ); - let layers = config - .config_layer_stack - .get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) - .into_iter() - .cloned() - .collect(); - 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() - }, - RequirementSource::CloudRequirements, - )); - 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() + let mut builder = test_codex() + .with_home(home) + .with_cloud_requirements(managed_network_requirements_loader()) + .with_config(move |config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); + config.permissions.permission_profile = Constrained::allow_any( + PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy_for_config), + ); }); - config.config_layer_stack = - match ConfigLayerStack::new(layers, requirements, requirements_toml) { - Ok(stack) => stack, - Err(err) => panic!("rebuild config layer stack with network requirements: {err}"), - }; - }); let test = builder.build_remote_aware(server).await?; assert!( test.config.permissions.network.is_some(), diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 4f3eb5b11c..7665a4ca8b 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -2,6 +2,7 @@ use crate::FeatureConfig; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeMap; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] @@ -54,3 +55,63 @@ impl FeatureConfig for AppsMcpPathOverrideConfigToml { self.enabled = Some(enabled); } } + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NetworkProxyConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_socks5: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub socks_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_socks5_udp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_upstream_proxy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dangerously_allow_non_loopback_proxy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dangerously_allow_all_unix_sockets: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub domains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub unix_sockets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_local_binding: Option, +} + +impl FeatureConfig for NetworkProxyConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NetworkProxyModeToml { + Limited, + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NetworkProxyDomainPermissionToml { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NetworkProxyUnixSocketPermissionToml { + Allow, + None, +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3cddefa8d9..955ae44c00 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -18,6 +18,10 @@ mod feature_configs; mod legacy; pub use feature_configs::AppsMcpPathOverrideConfigToml; pub use feature_configs::MultiAgentV2ConfigToml; +pub use feature_configs::NetworkProxyConfigToml; +pub use feature_configs::NetworkProxyDomainPermissionToml; +pub use feature_configs::NetworkProxyModeToml; +pub use feature_configs::NetworkProxyUnixSocketPermissionToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -140,6 +144,8 @@ pub enum Feature { ChildAgentsMd, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. EnableRequestCompression, + /// Start the managed network proxy for sandboxed sessions. + NetworkProxy, /// Enable collab tools. Collab, /// Enable task-path-based multi-agent routing. @@ -574,6 +580,7 @@ pub struct FeaturesToml { pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub apps_mcp_path_override: Option>, + pub network_proxy: Option>, /// Boolean feature toggles keyed by canonical or legacy feature name. #[serde(flatten)] entries: BTreeMap, @@ -599,6 +606,9 @@ impl FeaturesToml { { entries.insert(Feature::AppsMcpPathOverride.key().to_string(), enabled); } + if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::NetworkProxy.key().to_string(), enabled); + } entries } @@ -606,6 +616,7 @@ impl FeaturesToml { let Self { multi_agent_v2, apps_mcp_path_override, + network_proxy, entries, } = self; for key in legacy::legacy_feature_keys() { @@ -617,6 +628,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(multi_agent_v2, enabled); } else if spec.id == Feature::AppsMcpPathOverride { materialize_resolved_feature_enabled(apps_mcp_path_override, enabled); + } else if spec.id == Feature::NetworkProxy { + materialize_resolved_feature_enabled(network_proxy, enabled); } else { entries.insert(spec.key.to_string(), enabled); } @@ -881,6 +894,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::NetworkProxy, + key: "network_proxy", + stage: Stage::Experimental { + name: "Network proxy", + menu_description: "Apply network proxy restrictions to sandboxed sessions that already have network access.", + announcement: "NEW: Network proxy can now be enabled from /experimental. Restart Codex after enabling it.", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::Collab, key: "multi_agent", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index b546fc89c9..a635ca0740 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -164,6 +164,19 @@ fn tool_suggest_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSuggest.default_enabled(), true); } +#[test] +fn network_proxy_is_experimental_and_disabled_by_default() { + assert_eq!( + feature_for_key("network_proxy"), + Some(Feature::NetworkProxy) + ); + assert!(matches!( + Feature::NetworkProxy.stage(), + Stage::Experimental { .. } + )); + assert_eq!(Feature::NetworkProxy.default_enabled(), false); +} + #[test] fn tool_search_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSearch.stage(), Stage::Stable); @@ -531,6 +544,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( let mut features = Features::with_defaults(); features.enable(Feature::CodeMode); features.enable(Feature::MultiAgentV2); + features.enable(Feature::NetworkProxy); features.disable(Feature::ToolSearch); let mut features_toml = FeaturesToml { @@ -539,6 +553,11 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( min_wait_timeout_ms: Some(2500), ..Default::default() })), + network_proxy: Some(FeatureToml::Config(crate::NetworkProxyConfigToml { + enabled: Some(false), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + ..Default::default() + })), entries: BTreeMap::from([("include_apply_patch_tool".to_string(), true)]), ..Default::default() }; @@ -563,6 +582,14 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( ..Default::default() })) ); + assert_eq!( + features_toml.network_proxy, + Some(FeatureToml::Config(crate::NetworkProxyConfigToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + ..Default::default() + })) + ); let replayed = Features::from_sources( FeatureConfigSource { features: Some(&features_toml),