diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 76656088a5..c161636e07 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1634,13 +1634,30 @@ impl CodexMessageProcessor { let timeout_ms = params .timeout_ms .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); + let started_network_proxy = match self.config.network.as_ref() { + Some(spec) => match spec.start_proxy().await { + Ok(started) => Some(started), + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start managed network proxy: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }, + None => None, + }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); let exec_params = ExecParams { command: params.command, cwd, expiration: timeout_ms.into(), env, - network: self.config.network.clone(), + network: started_network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy), sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, @@ -1668,9 +1685,11 @@ impl CodexMessageProcessor { let outgoing = self.outgoing.clone(); let request_for_task = request; let sandbox_cwd = self.config.cwd.clone(); + let started_network_proxy_for_task = started_network_proxy; let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap); tokio::spawn(async move { + let _started_network_proxy = started_network_proxy_for_task; match codex_core::exec::process_exec_tool_call( exec_params, &effective_policy, diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 308e835837..39255b8d04 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -130,10 +130,7 @@ async fn run_command_under_sandbox( let sandbox_policy_cwd = cwd.clone(); let stdio_policy = StdioPolicy::Inherit; - let mut env = create_env(&config.shell_environment_policy, None); - if let Some(network) = config.network.as_ref() { - network.apply_to_env(&mut env); - } + let env = create_env(&config.shell_environment_policy, None); // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. if let SandboxType::Windows = sandbox_type { @@ -216,6 +213,19 @@ async fn run_command_under_sandbox( #[cfg(not(target_os = "macos"))] let _ = log_denials; + // This proxy should only live for the lifetime of the child process. + let network_proxy = match config.network.as_ref() { + Some(spec) => Some( + spec.start_proxy() + .await + .map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?, + ), + None => None, + }; + let network = network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy); + let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { @@ -225,7 +235,7 @@ async fn run_command_under_sandbox( config.sandbox_policy.get(), sandbox_policy_cwd.as_path(), stdio_policy, - None, + network.as_ref(), env, ) .await? @@ -245,7 +255,7 @@ async fn run_command_under_sandbox( sandbox_policy_cwd.as_path(), use_bwrap_sandbox, stdio_policy, - None, + network.as_ref(), env, ) .await? diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b9ef007e81..81512490eb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -44,6 +44,7 @@ use crate::turn_metadata::resolve_turn_metadata_header_with_timeout; use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; +use codex_network_proxy::NetworkProxy; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::config_types::ModeKind; @@ -113,6 +114,7 @@ use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; +use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; @@ -539,6 +541,7 @@ pub(crate) struct TurnContext { pub(crate) personality: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) tools_config: ToolsConfig, @@ -803,6 +806,7 @@ impl Session { session_configuration: &SessionConfiguration, per_turn_config: Config, model_info: ModelInfo, + network: Option, sub_id: String, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); @@ -842,6 +846,7 @@ impl Session { personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), + network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, @@ -1064,6 +1069,13 @@ impl Session { }; session_configuration.thread_name = thread_name.clone(); let mut state = SessionState::new(session_configuration.clone()); + let network_proxy = + match config.network.as_ref() { + Some(spec) => Some(spec.start_proxy().await.map_err(|err| { + anyhow::anyhow!("failed to start managed network proxy: {err}") + })?), + None => None, + }; let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -1086,6 +1098,7 @@ impl Session { skills_manager, file_watcher, agent_control, + network_proxy, state_db: state_db_ctx.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -1559,6 +1572,10 @@ impl Session { &session_configuration, per_turn_config, model_info, + self.services + .network_proxy + .as_ref() + .map(StartedNetworkProxy::proxy), sub_id, ); @@ -3702,6 +3719,7 @@ async fn spawn_review_thread( personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), + network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), @@ -6188,6 +6206,7 @@ mod tests { skills_manager, file_watcher, agent_control, + network_proxy: None, state_db: None, model_client: ModelClient::new( Some(auth_manager.clone()), @@ -6211,6 +6230,7 @@ mod tests { &session_configuration, per_turn_config, model_info, + None, "turn_id".to_string(), ); @@ -6321,6 +6341,7 @@ mod tests { skills_manager, file_watcher, agent_control, + network_proxy: None, state_db: None, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), @@ -6344,6 +6365,7 @@ mod tests { &session_configuration, per_turn_config, model_info, + None, "turn_id".to_string(), )); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 40d8a1f047..a923b0ef78 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -47,7 +47,6 @@ use crate::protocol::SandboxPolicy; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; -use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModeKind; @@ -80,6 +79,7 @@ use toml_edit::DocumentMut; mod constraint; pub mod edit; +mod network_proxy_spec; pub mod profile; pub mod schema; pub mod service; @@ -88,6 +88,8 @@ pub use constraint::Constrained; pub use constraint::ConstraintError; pub use constraint::ConstraintResult; +pub use network_proxy_spec::NetworkProxySpec; +pub use network_proxy_spec::StartedNetworkProxy; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -154,7 +156,7 @@ pub struct Config { pub enforce_residency: Constrained>, /// Effective network configuration applied to all spawned processes. - pub network: Option, + pub network: Option, /// True if the user passed in an override or set a value in config.toml /// for either of approval_policy or sandbox_mode. @@ -1657,7 +1659,7 @@ impl Config { mcp_servers, exec_policy: _, enforce_residency, - network: _network_requirements, + network: network_requirements, } = requirements; apply_requirement_constrained_value( @@ -1682,6 +1684,20 @@ impl Config { let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + let network = match network_requirements { + Some(Sourced { value, source }) => { + let network = NetworkProxySpec::from_constraints(&config_layer_stack, value) + .map_err(|err| { + std::io::Error::new( + err.kind(), + format!("failed to build managed network proxy from {source}: {err}"), + ) + })?; + Some(network) + } + None => None, + }; + let config = Self { model, review_model, @@ -1694,7 +1710,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, enforce_residency: enforce_residency.value, - network: None, + network, did_user_set_custom_approval_policy_or_sandbox_mode, forced_auto_mode_downgraded_on_windows, shell_environment_policy, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs new file mode 100644 index 0000000000..e50a30d885 --- /dev/null +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -0,0 +1,167 @@ +use crate::config; +use crate::config_loader::NetworkConstraints; +use async_trait::async_trait; +use codex_network_proxy::ConfigReloader; +use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkProxyConstraints; +use codex_network_proxy::NetworkProxyHandle; +use codex_network_proxy::NetworkProxyState; +use codex_network_proxy::build_config_state; +use codex_network_proxy::host_and_port_from_network_addr; +use codex_network_proxy::validate_policy_against_constraints; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkProxySpec { + config: NetworkProxyConfig, + constraints: NetworkProxyConstraints, +} + +pub struct StartedNetworkProxy { + proxy: NetworkProxy, + _handle: NetworkProxyHandle, +} + +impl StartedNetworkProxy { + fn new(proxy: NetworkProxy, handle: NetworkProxyHandle) -> Self { + Self { + proxy, + _handle: handle, + } + } + + pub fn proxy(&self) -> NetworkProxy { + self.proxy.clone() + } +} + +#[derive(Clone)] +struct StaticNetworkProxyReloader { + state: ConfigState, +} + +impl StaticNetworkProxyReloader { + fn new(state: ConfigState) -> Self { + Self { state } + } +} + +#[async_trait] +impl ConfigReloader for StaticNetworkProxyReloader { + async fn maybe_reload(&self) -> anyhow::Result> { + Ok(None) + } + + async fn reload_now(&self) -> anyhow::Result { + Ok(self.state.clone()) + } + + fn source_label(&self) -> String { + "StaticNetworkProxyReloader".to_string() + } +} + +impl NetworkProxySpec { + pub fn proxy_host_and_port(&self) -> String { + host_and_port_from_network_addr(&self.config.network.proxy_url, 3128) + } + + pub(crate) fn from_constraints( + _config_layer_stack: &config::ConfigLayerStack, + requirements: NetworkConstraints, + ) -> std::io::Result { + // TODO(mbolin): Use ConfigLayerStack once we are ready to start + // honoring network configuration in config.toml. + let config = NetworkProxyConfig::default(); + let (config, constraints) = Self::apply_requirements(config, &requirements); + validate_policy_against_constraints(&config, &constraints).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("network proxy constraints are invalid: {err}"), + ) + })?; + Ok(Self { + config, + constraints, + }) + } + + pub async fn start_proxy(&self) -> std::io::Result { + let state = + build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| { + std::io::Error::other(format!("failed to build network proxy state: {err}")) + })?; + let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone())); + let state = NetworkProxyState::with_reloader(state, reloader); + let proxy = NetworkProxy::builder() + .state(Arc::new(state)) + .build() + .await + .map_err(|err| { + std::io::Error::other(format!("failed to build network proxy: {err}")) + })?; + let handle = proxy + .run() + .await + .map_err(|err| std::io::Error::other(format!("failed to run network proxy: {err}")))?; + Ok(StartedNetworkProxy::new(proxy, handle)) + } + + fn apply_requirements( + mut config: NetworkProxyConfig, + requirements: &NetworkConstraints, + ) -> (NetworkProxyConfig, NetworkProxyConstraints) { + let mut constraints = NetworkProxyConstraints::default(); + + if let Some(enabled) = requirements.enabled { + config.network.enabled = enabled; + constraints.enabled = Some(enabled); + } + if let Some(http_port) = requirements.http_port { + config.network.proxy_url = format!("http://127.0.0.1:{http_port}"); + } + if let Some(socks_port) = requirements.socks_port { + config.network.socks_url = format!("http://127.0.0.1:{socks_port}"); + } + if let Some(allow_upstream_proxy) = requirements.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + constraints.allow_upstream_proxy = Some(allow_upstream_proxy); + } + if let Some(dangerously_allow_non_loopback_proxy) = + requirements.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + constraints.dangerously_allow_non_loopback_proxy = + Some(dangerously_allow_non_loopback_proxy); + } + if let Some(dangerously_allow_non_loopback_admin) = + requirements.dangerously_allow_non_loopback_admin + { + config.network.dangerously_allow_non_loopback_admin = + dangerously_allow_non_loopback_admin; + constraints.dangerously_allow_non_loopback_admin = + Some(dangerously_allow_non_loopback_admin); + } + if let Some(allowed_domains) = requirements.allowed_domains.clone() { + config.network.allowed_domains = allowed_domains.clone(); + constraints.allowed_domains = Some(allowed_domains); + } + if let Some(denied_domains) = requirements.denied_domains.clone() { + config.network.denied_domains = denied_domains.clone(); + constraints.denied_domains = Some(denied_domains); + } + if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() { + config.network.allow_unix_sockets = allow_unix_sockets.clone(); + constraints.allow_unix_sockets = Some(allow_unix_sockets); + } + if let Some(allow_local_binding) = requirements.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + constraints.allow_local_binding = Some(allow_local_binding); + } + + (config, constraints) + } +} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 0438119d42..04beef77bc 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -5,6 +5,7 @@ use crate::RolloutRecorder; use crate::agent::AgentControl; use crate::analytics_client::AnalyticsEventsClient; use crate::client::ModelClient; +use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; use crate::file_watcher::FileWatcher; use crate::hooks::Hooks; @@ -38,6 +39,7 @@ pub(crate) struct SessionServices { pub(crate) skills_manager: Arc, pub(crate) file_watcher: Arc, pub(crate) agent_control: AgentControl, + pub(crate) network_proxy: Option, pub(crate) state_db: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 02dd5fdf24..b0c505b64a 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -147,7 +147,7 @@ pub(crate) async fn execute_user_shell_command( &turn_context.shell_environment_policy, Some(session.conversation_id), ), - network: turn_context.config.network.clone(), + network: turn_context.network.clone(), // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 18c594d997..b9e2a97d6d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -53,7 +53,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.config.network.clone(), + network: turn_context.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -82,7 +82,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.config.network.clone(), + network: turn_context.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -444,7 +444,7 @@ mod tests { assert_eq!(exec_params.command, expected_command); assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); - assert_eq!(exec_params.network, turn_context.config.network); + assert_eq!(exec_params.network, turn_context.network); assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); assert_eq!(exec_params.justification, justification); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 0a88283fe9..c06889b37b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -192,7 +192,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, - network: context.turn.config.network.clone(), + network: context.turn.network.clone(), tty, sandbox_permissions, justification, diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index e4ef202a45..505fa96d32 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -8,13 +8,13 @@ use std::net::SocketAddr; use tracing::warn; use url::Url; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct NetworkProxyConfig { #[serde(default)] pub network: NetworkProxySettings, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct NetworkProxySettings { #[serde(default)] pub enabled: bool, @@ -205,6 +205,30 @@ fn resolve_addr(url: &str, default_port: u16) -> Result { } } +pub fn host_and_port_from_network_addr(value: &str, default_port: u16) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return "".to_string(); + } + + let parts = match parse_host_port(trimmed, default_port) { + Ok(parts) => parts, + Err(_) => { + return format_host_and_port(trimmed, default_port); + } + }; + + format_host_and_port(&parts.host, parts.port) +} + +fn format_host_and_port(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SocketAddressParts { host: String, @@ -280,14 +304,13 @@ fn parse_host_port_fallback(input: &str, default_port: u16) -> Result() { if host.is_empty() { bail!("missing host in network proxy address: {input}"); } return Ok(SocketAddressParts { host: host.to_string(), - port, + port: port.parse::().ok().unwrap_or(default_port), }); } @@ -376,12 +399,25 @@ mod tests { assert_eq!( parse_host_port("example.com:notaport", 3128).unwrap(), SocketAddressParts { - host: "example.com:notaport".to_string(), + host: "example.com".to_string(), port: 3128, } ); } + #[test] + fn host_and_port_from_network_addr_defaults_for_empty_string() { + assert_eq!(host_and_port_from_network_addr("", 1234), ""); + } + + #[test] + fn host_and_port_from_network_addr_formats_ipv6() { + assert_eq!( + host_and_port_from_network_addr("http://[::1]:8080", 3128), + "[::1]:8080" + ); + } + #[test] fn resolve_addr_maps_localhost_to_loopback() { assert_eq!( diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 570a8a9945..8ca0d969c8 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -15,6 +15,7 @@ mod upstream; pub use config::NetworkMode; pub use config::NetworkProxyConfig; +pub use config::host_and_port_from_network_addr; pub use network_policy::NetworkDecision; pub use network_policy::NetworkPolicyDecider; pub use network_policy::NetworkPolicyRequest;