Compare commits

...

9 Commits

Author SHA1 Message Date
viyatb-oai
5f85d1ce10 test: preserve network proxy matrix on windows
Co-authored-by: Codex noreply@openai.com
2026-05-01 18:56:38 -07:00
viyatb-oai
e19744ed58 fix: keep proxy feature gated by network access
Co-authored-by: Codex noreply@openai.com
2026-05-01 18:11:08 -07:00
viyatb-oai
46ab37fdec test: cover network proxy behavior matrix
Co-authored-by: Codex noreply@openai.com
2026-05-01 17:51:19 -07:00
viyatb-oai
d51647a1c3 test: cover bare network proxy feature toggle
Co-authored-by: Codex noreply@openai.com
2026-05-01 17:31:49 -07:00
viyatb-oai
49610cbe9b test: use managed requirements in unified exec proxy tests
Co-authored-by: Codex noreply@openai.com
2026-05-01 16:14:40 -07:00
viyatb-oai
bae703b98a fix: support network proxy feature materialization
Co-authored-by: Codex noreply@openai.com
2026-05-01 14:15:56 -07:00
viyatb-oai
38e54e9b82 fix: gate profile proxy startup on feature
Co-authored-by: Codex noreply@openai.com
2026-05-01 14:08:33 -07:00
viyatb-oai
c587ef7368 fix: preserve profile network proxy behavior
Co-authored-by: Codex noreply@openai.com
2026-05-01 14:08:33 -07:00
viyatb-oai
662d0fa67f feat: add network proxy feature flag
Co-authored-by: Codex noreply@openai.com
2026-05-01 14:08:32 -07:00
11 changed files with 761 additions and 134 deletions

View File

@@ -58,7 +58,7 @@ use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_features::FEATURES;
use codex_features::Stage;
use codex_features::is_known_feature_key;
use codex_features::feature_toggle_override_key;
use codex_login::AuthManager;
use codex_memories_write::clear_memory_roots_contents;
use codex_models_manager::bundled_models_response;
@@ -676,22 +676,23 @@ impl FeatureToggles {
fn to_overrides(&self) -> anyhow::Result<Vec<String>> {
let mut v = Vec::new();
for feature in &self.enable {
Self::validate_feature(feature)?;
v.push(format!("features.{feature}=true"));
let key = Self::feature_override_key(feature)?;
v.push(format!("{key}=true"));
}
for feature in &self.disable {
Self::validate_feature(feature)?;
v.push(format!("features.{feature}=false"));
let key = Self::feature_override_key(feature)?;
v.push(format!("{key}=false"));
}
Ok(v)
}
fn feature_override_key(feature: &str) -> anyhow::Result<String> {
feature_toggle_override_key(feature)
.ok_or_else(|| anyhow::anyhow!("Unknown feature flag: {feature}"))
}
fn validate_feature(feature: &str) -> anyhow::Result<()> {
if is_known_feature_key(feature) {
Ok(())
} else {
anyhow::bail!("Unknown feature flag: {feature}")
}
Self::feature_override_key(feature).map(|_| ())
}
}
@@ -2568,6 +2569,22 @@ mod tests {
);
}
#[test]
fn feature_toggles_preserve_configurable_feature_tables() {
let toggles = FeatureToggles {
enable: vec!["network_proxy".to_string()],
disable: vec!["multi_agent_v2".to_string()],
};
let overrides = toggles.to_overrides().expect("valid features");
assert_eq!(
overrides,
vec![
"features.network_proxy.enabled=true".to_string(),
"features.multi_agent_v2.enabled=false".to_string(),
]
);
}
#[test]
fn feature_toggles_accept_legacy_linux_sandbox_flag() {
let toggles = FeatureToggles {

View File

@@ -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::<codex_features::FeatureToml<
codex_features::NetworkProxyConfigToml,
>>(),
);
continue;
}
validation
.properties
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());

View File

@@ -481,6 +481,9 @@
"multi_agent_v2": {
"$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml"
},
"network_proxy": {
"$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml"
},
"personality": {
"type": "boolean"
},
@@ -785,6 +788,16 @@
}
]
},
"FeatureToml_for_NetworkProxyConfigToml": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/NetworkProxyConfigToml"
}
]
},
"FeedbackConfigToml": {
"additionalProperties": false,
"properties": {
@@ -1460,6 +1473,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": {
@@ -3947,6 +4029,9 @@
"multi_agent_v2": {
"$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml"
},
"network_proxy": {
"$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml"
},
"personality": {
"type": "boolean"
},

View File

@@ -733,59 +733,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()),
@@ -818,6 +771,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
@@ -826,11 +1000,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(())
}

View File

@@ -67,6 +67,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;
@@ -1954,6 +1956,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<WebSearchMode>,
permission_profile: &PermissionProfile,
@@ -2137,6 +2146,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);
@@ -2220,7 +2230,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,
@@ -2433,6 +2443,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();

View File

@@ -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>(

View File

@@ -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!(

View File

@@ -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(),

View File

@@ -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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_socks5: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub socks_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_socks5_udp: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_upstream_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dangerously_allow_non_loopback_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<NetworkProxyModeToml>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domains: Option<BTreeMap<String, NetworkProxyDomainPermissionToml>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unix_sockets: Option<BTreeMap<String, NetworkProxyUnixSocketPermissionToml>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_local_binding: Option<bool>,
}
impl FeatureConfig for NetworkProxyConfigToml {
fn enabled(&self) -> Option<bool> {
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,
}

View File

@@ -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.
@@ -244,6 +250,13 @@ impl Feature {
self.info().default_enabled
}
pub fn uses_config_table(self) -> bool {
matches!(
self,
Feature::MultiAgentV2 | Feature::AppsMcpPathOverride | Feature::NetworkProxy
)
}
fn info(self) -> &'static FeatureSpec {
FEATURES
.iter()
@@ -559,6 +572,15 @@ pub fn is_known_feature_key(key: &str) -> bool {
feature_for_key(key).is_some()
}
pub fn feature_toggle_override_key(key: &str) -> Option<String> {
let feature = feature_for_key(key)?;
if feature.uses_config_table() {
Some(format!("features.{}.enabled", feature.key()))
} else {
Some(format!("features.{key}"))
}
}
/// Deserializable features table for TOML.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
pub struct FeaturesToml {
@@ -566,6 +588,7 @@ pub struct FeaturesToml {
pub multi_agent_v2: Option<FeatureToml<MultiAgentV2ConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps_mcp_path_override: Option<FeatureToml<AppsMcpPathOverrideConfigToml>>,
pub network_proxy: Option<FeatureToml<NetworkProxyConfigToml>>,
/// Boolean feature toggles keyed by canonical or legacy feature name.
#[serde(flatten)]
entries: BTreeMap<String, bool>,
@@ -591,6 +614,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
}
@@ -598,6 +624,7 @@ impl FeaturesToml {
let Self {
multi_agent_v2,
apps_mcp_path_override,
network_proxy,
entries,
} = self;
for key in legacy::legacy_feature_keys() {
@@ -609,6 +636,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);
}
@@ -873,6 +902,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: "Start Codex's managed network proxy for sandboxed sessions. The active permissions profile still controls direct 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",

View File

@@ -138,6 +138,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);
@@ -495,6 +508,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 {
@@ -503,6 +517,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()
};
@@ -527,6 +546,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),