feat: add network proxy feature flag

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-04-28 22:54:45 -07:00
parent 80a408e201
commit 8d4dde8b2a
9 changed files with 481 additions and 11 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;
@@ -687,22 +687,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(|_| ())
}
}
@@ -2659,6 +2660,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

@@ -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"
},

View File

@@ -891,6 +891,172 @@ async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std
Ok(())
}
#[tokio::test]
async fn network_proxy_feature_starts_proxy_without_enabling_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(
r#"
[network_proxy]
enabled = true
proxy_url = "http://127.0.0.1:43128"
enable_socks5 = false
"#,
)
.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
);
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 network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
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.proxy_url".to_string(),
toml::Value::String("http://127.0.0.1:43128".to_string()),
),
(
"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::Restricted
);
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 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!(
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 permissions_profiles_network_disabled_by_default_does_not_start_proxy()
-> std::io::Result<()> {

View File

@@ -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::BuiltinMcpServerOptions;
@@ -111,6 +112,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;
@@ -2025,6 +2027,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,
@@ -2215,6 +2224,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);
@@ -2298,7 +2308,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,
@@ -2511,6 +2521,15 @@ impl Config {
None,
)
};
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);
}
if enable_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;
@@ -139,6 +148,65 @@ fn profile_network_requires_proxy(network: &NetworkToml) -> bool {
|| network.allow_local_binding == Some(true)
}
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()
.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>(
permissions: &'a PermissionsToml,
profile_name: &str,

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,59 @@ 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
}
}
#[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;
@@ -142,6 +146,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.
@@ -252,6 +258,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()
@@ -567,6 +580,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 {
@@ -574,6 +596,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>,
@@ -599,6 +622,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
}
@@ -887,6 +913,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

@@ -171,6 +171,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);