fix(exec-server): start managed proxy on executor

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-07-07 11:24:22 -07:00
co-authored by Codex noreply@openai.com
parent a3f8b0b332
commit 55b9447a17
15 changed files with 327 additions and 22 deletions
@@ -42,6 +42,7 @@ use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::RemoteNetworkProxyConfig;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -324,6 +325,26 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
));
let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
let (env, managed_network_context) = match managed_network {
Some(network) if environment_is_remote => {
let config = network.current_cfg().await.map_err(|err| {
ToolError::Codex(CodexErr::Io(io::Error::other(format!(
"failed to read effective network proxy config: {err}"
))))
})?;
let proxy_config = RemoteNetworkProxyConfig::from_effective_config(&config)
.map_err(|err| {
ToolError::Codex(CodexErr::Io(io::Error::other(err.to_string())))
})?;
let allow_local_binding = proxy_config.allow_local_binding;
(
env,
Some(codex_network_proxy::ManagedNetworkSandboxContext {
loopback_ports: Vec::new(),
allow_local_binding,
proxy_config: Some(proxy_config),
}),
)
}
Some(network) => {
let prepared = network
.prepare_for_optional_environment(
+2 -5
View File
@@ -475,10 +475,7 @@ impl<'a> SandboxAttempt<'a> {
&self,
command: SandboxCommand,
options: ExecOptions,
network: Option<&NetworkProxy>,
environment_id: Option<&str>,
) -> Result<crate::sandboxing::ExecRequest, CodexErr> {
let network = self.network_proxy(network);
let managed_network = command.managed_network.clone();
let exec_server_permissions = effective_permission_profile(
self.exec_server_permissions,
@@ -492,8 +489,8 @@ impl<'a> SandboxAttempt<'a> {
// The exec-server must receive the native command, not this host's wrapper.
sandbox: SandboxType::None,
enforce_managed_network: self.enforce_managed_network,
environment_id,
network,
environment_id: None,
network: None,
sandbox_policy_cwd: self.sandbox_cwd,
codex_linux_sandbox_exe: None,
use_legacy_landlock: self.use_legacy_landlock,
+3 -2
View File
@@ -232,6 +232,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: false,
proxy_config: None,
};
let command = || SandboxCommand {
program: "/bin/bash".into(),
@@ -246,7 +247,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
};
let request = attempt
.env_for_exec_server(command(), options(), /*network*/ None, Some("remote"))
.env_for_exec_server(command(), options())
.expect("prepare remote exec request");
assert_eq!(
@@ -278,7 +279,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() {
attempt.sandbox_requested = false;
let request = attempt
.env_for_exec_server(command(), options(), /*network*/ None, Some("remote"))
.env_for_exec_server(command(), options())
.expect("prepare unsandboxed remote exec request");
assert_eq!(request.exec_server_sandbox, None);
@@ -922,7 +922,7 @@ impl UnifiedExecProcessManager {
environment: &codex_exec_server::Environment,
) -> Result<UnifiedExecProcess, ToolError> {
let mut request = if environment.is_remote() {
attempt.env_for_exec_server(command, options, network, environment_id)
attempt.env_for_exec_server(command, options)
} else {
attempt.env_for(command, options, network, environment_id)
}
@@ -117,6 +117,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: false,
proxy_config: None,
};
let mut request = ExecRequest {
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],
@@ -598,6 +598,7 @@ mod tests {
managed_network: Some(ManagedNetworkSandboxContext {
loopback_ports: vec![43123, 48081],
allow_local_binding: false,
proxy_config: None,
}),
};
+13 -4
View File
@@ -8,6 +8,7 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_exec_server_protocol::JSONRPCErrorError;
use codex_network_proxy::NetworkProxyHandle;
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::exec_output::ExecToolCallOutput;
@@ -97,6 +98,7 @@ struct RunningProcess {
termination_requested: bool,
sandbox: SandboxType,
sandbox_denied: bool,
network_proxy_handle: Option<NetworkProxyHandle>,
}
/// Bounded cache of stdin write ids that have already been accepted for one process.
@@ -235,7 +237,7 @@ impl LocalProcess {
) -> Result<(ExecResponse, watch::Sender<u64>, ExecProcessEventLog), JSONRPCErrorError> {
let process_id = params.process_id.clone();
let prepared =
prepare_exec_request(&params, child_env(&params), self.runtime_paths.as_ref())?;
prepare_exec_request(&params, child_env(&params), self.runtime_paths.as_ref()).await?;
let (program, args) = prepared
.command
.split_first()
@@ -338,6 +340,7 @@ impl LocalProcess {
termination_requested: false,
sandbox: prepared.sandbox,
sandbox_denied: false,
network_proxy_handle: prepared.network_proxy_handle,
})),
);
}
@@ -823,7 +826,7 @@ async fn watch_exit(
output_notify: Arc<Notify>,
) {
let exit_code = exit_rx.await.unwrap_or(-1);
let sandboxed = {
let (sandboxed, network_proxy_handle) = {
let mut processes = inner.processes.lock().await;
match processes.get_mut(&process_id) {
Some(ProcessEntry::Running(process)) => {
@@ -837,11 +840,16 @@ async fn watch_exit(
"error"
});
}
sandboxed
(sandboxed, process.network_proxy_handle.take())
}
Some(ProcessEntry::Starting(_)) | None => false,
Some(ProcessEntry::Starting(_)) | None => (false, None),
}
};
if let Some(network_proxy_handle) = network_proxy_handle
&& let Err(err) = network_proxy_handle.shutdown().await
{
tracing::warn!("failed to shut down executor network proxy: {err}");
}
if sandboxed {
let _ = tokio::time::timeout(Duration::from_millis(20), output_notify.notified()).await;
}
@@ -1310,6 +1318,7 @@ mod tests {
termination_requested: false,
sandbox: SandboxType::None,
sandbox_denied: false,
network_proxy_handle: None,
})),
);
assert!(previous.is_none());
+48 -3
View File
@@ -1,7 +1,12 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_exec_server_protocol::JSONRPCErrorError;
use codex_network_proxy::CUSTOM_CA_ENV_KEYS;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyHandle;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path;
use codex_protocol::models::PermissionProfile;
use codex_sandboxing::SandboxCommand;
@@ -16,6 +21,7 @@ use codex_utils_path_uri::PathUri;
use crate::ExecServerRuntimePaths;
use crate::protocol::ExecParams;
use crate::rpc::internal_error;
use crate::rpc::invalid_params;
pub(crate) struct PreparedExecRequest {
@@ -24,13 +30,16 @@ pub(crate) struct PreparedExecRequest {
pub(crate) env: HashMap<String, String>,
pub(crate) arg0: Option<String>,
pub(crate) sandbox: SandboxType,
pub(crate) network_proxy_handle: Option<NetworkProxyHandle>,
}
pub(crate) fn prepare_exec_request(
pub(crate) async fn prepare_exec_request(
params: &ExecParams,
env: HashMap<String, String>,
runtime_paths: Option<&ExecServerRuntimePaths>,
) -> Result<PreparedExecRequest, JSONRPCErrorError> {
let (env, managed_network, network_proxy_handle) =
prepare_managed_network(params.managed_network.as_ref(), env).await?;
let Some(sandbox_context) = params.sandbox.as_ref() else {
return Ok(PreparedExecRequest {
command: params.argv.clone(),
@@ -38,6 +47,7 @@ pub(crate) fn prepare_exec_request(
env,
arg0: params.arg0.clone(),
sandbox: SandboxType::None,
network_proxy_handle,
});
};
let runtime_paths = runtime_paths
@@ -62,7 +72,7 @@ pub(crate) fn prepare_exec_request(
native_workspace_roots.as_slice()
};
let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots);
let managed_mitm_ca_trust_bundle_path = params.managed_network.as_ref().and_then(|_| {
let managed_mitm_ca_trust_bundle_path = managed_network.as_ref().and_then(|_| {
CUSTOM_CA_ENV_KEYS.iter().find_map(|key| {
let path = env.get(*key)?;
if !is_managed_mitm_ca_trust_bundle_path(path) {
@@ -117,7 +127,7 @@ pub(crate) fn prepare_exec_request(
args: args.to_vec(),
cwd: params.cwd.clone(),
env,
managed_network: params.managed_network.clone(),
managed_network,
additional_permissions: None,
},
permissions: &permissions,
@@ -139,9 +149,44 @@ pub(crate) fn prepare_exec_request(
env: request.env,
arg0: request.arg0,
sandbox: request.sandbox,
network_proxy_handle,
})
}
async fn prepare_managed_network(
managed_network: Option<&ManagedNetworkSandboxContext>,
env: HashMap<String, String>,
) -> Result<
(
HashMap<String, String>,
Option<ManagedNetworkSandboxContext>,
Option<NetworkProxyHandle>,
),
JSONRPCErrorError,
> {
let Some(proxy_config) = managed_network.and_then(|context| context.proxy_config.clone())
else {
return Ok((env, managed_network.cloned(), None));
};
let state = NetworkProxyState::from_static_config(proxy_config.into_network_proxy_config())
.map_err(|err| invalid_params(format!("invalid network proxy config: {err}")))?;
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.build()
.await
.map_err(|err| internal_error(format!("failed to build executor network proxy: {err}")))?;
let handle = proxy
.run()
.await
.map_err(|err| internal_error(format!("failed to start executor network proxy: {err}")))?;
let prepared = proxy
.prepare_for_optional_environment(env, /*environment_id*/ None)
.map_err(|err| {
internal_error(format!("failed to prepare executor network proxy: {err}"))
})?;
Ok((prepared.env, Some(prepared.sandbox_context), Some(handle)))
}
fn native_path(path: &PathUri, label: &str) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
path.to_abs_path().map_err(|err| {
invalid_params(format!(
@@ -1,12 +1,18 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::Duration;
#[cfg(target_os = "macos")]
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::RemoteNetworkProxyConfig;
#[cfg(unix)]
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::time::timeout;
use super::prepare_exec_request;
use crate::ExecParams;
@@ -17,8 +23,8 @@ use crate::FileSystemSandboxContext;
use crate::ProcessId;
#[cfg(unix)]
#[test]
fn sandbox_request_wraps_native_argv_on_executor() {
#[tokio::test]
async fn sandbox_request_wraps_native_argv_on_executor() {
let cwd: AbsolutePathBuf = std::env::current_dir()
.expect("current directory")
.try_into()
@@ -50,6 +56,7 @@ fn sandbox_request_wraps_native_argv_on_executor() {
};
let prepared = prepare_exec_request(&params, HashMap::new(), Some(&runtime_paths))
.await
.expect("prepare sandboxed request");
assert_ne!(prepared.command, params.argv);
@@ -82,8 +89,8 @@ fn sandbox_request_wraps_native_argv_on_executor() {
}
#[cfg(target_os = "macos")]
#[test]
fn sandbox_request_allows_prepared_managed_proxy_port() {
#[tokio::test]
async fn sandbox_request_allows_prepared_managed_proxy_port() {
let cwd: AbsolutePathBuf = std::env::current_dir()
.expect("current directory")
.try_into()
@@ -110,10 +117,12 @@ fn sandbox_request_allows_prepared_managed_proxy_port() {
managed_network: Some(ManagedNetworkSandboxContext {
loopback_ports: vec![43123],
allow_local_binding: false,
proxy_config: None,
}),
};
let prepared = prepare_exec_request(&params, HashMap::new(), Some(&runtime_paths))
.await
.expect("prepare managed-network sandbox request");
let policy = prepared
.command
@@ -124,8 +133,8 @@ fn sandbox_request_allows_prepared_managed_proxy_port() {
assert!(policy.contains("(allow network-outbound (remote ip \"localhost:43123\"))"));
}
#[test]
fn native_request_preserves_native_launch_fields() {
#[tokio::test]
async fn native_request_preserves_native_launch_fields() {
let cwd: AbsolutePathBuf = std::env::current_dir()
.expect("current directory")
.try_into()
@@ -147,6 +156,7 @@ fn native_request_preserves_native_launch_fields() {
};
let prepared = prepare_exec_request(&params, env.clone(), /*runtime_paths*/ None)
.await
.expect("prepare native request");
assert_eq!(prepared.command, params.argv);
@@ -154,3 +164,70 @@ fn native_request_preserves_native_launch_fields() {
assert_eq!(prepared.env, env);
assert_eq!(prepared.arg0, params.arg0);
}
#[tokio::test]
async fn remote_proxy_config_starts_executor_local_proxy() {
let cwd: AbsolutePathBuf = std::env::current_dir()
.expect("current directory")
.try_into()
.expect("absolute cwd");
let mut config = NetworkProxyConfig::default();
config.network.enabled = true;
config
.network
.set_allowed_domains(vec!["allowed.example".to_string()]);
let proxy_config = RemoteNetworkProxyConfig::from_effective_config(&config)
.expect("supported remote proxy config");
let params = ExecParams {
process_id: ProcessId::from("process-remote-proxy"),
argv: vec!["echo".to_string(), "hello".to_string()],
cwd: PathUri::from_abs_path(&cwd),
env_policy: None,
env: HashMap::new(),
tty: false,
pipe_stdin: false,
arg0: None,
sandbox: None,
enforce_managed_network: false,
managed_network: Some(ManagedNetworkSandboxContext {
loopback_ports: Vec::new(),
allow_local_binding: false,
proxy_config: Some(proxy_config),
}),
};
let stale_proxy = "http://127.0.0.1:9".to_string();
let env = HashMap::from([("HTTP_PROXY".to_string(), stale_proxy.clone())]);
let prepared = prepare_exec_request(&params, env, /*runtime_paths*/ None)
.await
.expect("prepare request with executor-local proxy");
let http_proxy = prepared.env.get("HTTP_PROXY").expect("HTTP proxy env");
assert_ne!(http_proxy, &stale_proxy);
assert!(http_proxy.starts_with("http://127.0.0.1:"));
let proxy_addr: SocketAddr = http_proxy
.strip_prefix("http://")
.expect("HTTP proxy scheme")
.parse()
.expect("HTTP proxy address");
let mut stream = tokio::net::TcpStream::connect(proxy_addr)
.await
.expect("connect to executor proxy");
stream
.write_all(b"CONNECT blocked.example:443 HTTP/1.1\r\nHost: blocked.example:443\r\n\r\n")
.await
.expect("write CONNECT request");
let mut response = [0_u8; 256];
let response_len = timeout(Duration::from_secs(2), stream.read(&mut response))
.await
.expect("proxy response timeout")
.expect("read proxy response");
assert!(String::from_utf8_lossy(&response[..response_len]).starts_with("HTTP/1.1 403"));
prepared
.network_proxy_handle
.expect("running executor proxy")
.shutdown()
.await
.expect("shut down executor proxy");
}
+2
View File
@@ -13,6 +13,7 @@ mod network_policy;
mod policy;
mod proxy;
mod reasons;
mod remote_config;
mod responses;
mod runtime;
mod socks5;
@@ -67,6 +68,7 @@ pub use proxy::PROXY_URL_ENV_KEYS;
pub use proxy::PreparedManagedNetwork;
pub use proxy::has_proxy_url_env_vars;
pub use proxy::proxy_url_env_value;
pub use remote_config::RemoteNetworkProxyConfig;
pub use runtime::BlockedRequest;
pub use runtime::BlockedRequestArgs;
pub use runtime::BlockedRequestObserver;
+5
View File
@@ -348,6 +348,9 @@ pub struct ManagedNetworkSandboxContext {
/// Whether the command may bind local sockets and exchange loopback traffic.
#[serde(default)]
pub allow_local_binding: bool,
/// Effective proxy settings for an exec-server that must start its own local listeners.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<crate::RemoteNetworkProxyConfig>,
}
/// Environment-specific managed-network settings prepared for one command launch.
@@ -727,6 +730,7 @@ impl NetworkProxy {
sandbox_context: ManagedNetworkSandboxContext {
loopback_ports,
allow_local_binding: runtime_settings.allow_local_binding,
proxy_config: None,
},
}
}
@@ -1216,6 +1220,7 @@ mod tests {
ManagedNetworkSandboxContext {
loopback_ports: expected_ports,
allow_local_binding: false,
proxy_config: None,
}
);
}
@@ -0,0 +1,70 @@
use anyhow::Result;
use anyhow::ensure;
use serde::Deserialize;
use serde::Serialize;
use crate::NetworkDomainPermissions;
use crate::NetworkMode;
use crate::NetworkProxyConfig;
use crate::NetworkUnixSocketPermissions;
/// Effective network proxy settings that are safe to send to a remote executor.
///
/// Listener addresses are deliberately omitted because the executor chooses its own loopback
/// ports. MITM, credential injection, and hooks are not represented so their configuration cannot
/// cross the exec-server boundary accidentally.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteNetworkProxyConfig {
pub enabled: bool,
pub enable_socks5: bool,
pub enable_socks5_udp: bool,
pub allow_upstream_proxy: bool,
pub dangerously_allow_all_unix_sockets: bool,
pub mode: NetworkMode,
pub domains: Option<NetworkDomainPermissions>,
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
pub allow_local_binding: bool,
}
impl RemoteNetworkProxyConfig {
pub fn from_effective_config(config: &NetworkProxyConfig) -> Result<Self> {
let settings = &config.network;
ensure!(
!settings.mitm
&& !settings.credential_broker
&& !settings.dangerously_allow_plaintext_credential_injection
&& settings.mitm_hooks.is_empty(),
"remote exec-server network proxy does not support MITM, credential injection, or MITM hooks"
);
Ok(Self {
enabled: settings.enabled,
enable_socks5: settings.enable_socks5,
enable_socks5_udp: settings.enable_socks5_udp,
allow_upstream_proxy: settings.allow_upstream_proxy,
dangerously_allow_all_unix_sockets: settings.dangerously_allow_all_unix_sockets,
mode: settings.mode,
domains: settings.domains.clone(),
unix_sockets: settings.unix_sockets.clone(),
allow_local_binding: settings.allow_local_binding,
})
}
pub fn into_network_proxy_config(self) -> NetworkProxyConfig {
let mut config = NetworkProxyConfig::default();
config.network.enabled = self.enabled;
config.network.enable_socks5 = self.enable_socks5;
config.network.enable_socks5_udp = self.enable_socks5_udp;
config.network.allow_upstream_proxy = self.allow_upstream_proxy;
config.network.dangerously_allow_all_unix_sockets = self.dangerously_allow_all_unix_sockets;
config.network.mode = self.mode;
config.network.domains = self.domains;
config.network.unix_sockets = self.unix_sockets;
config.network.allow_local_binding = self.allow_local_binding;
config
}
}
#[cfg(test)]
#[path = "remote_config_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
use pretty_assertions::assert_eq;
use super::RemoteNetworkProxyConfig;
use crate::NetworkMode;
use crate::NetworkProxyConfig;
#[test]
fn round_trip_preserves_supported_effective_settings() {
let mut config = NetworkProxyConfig::default();
config.network.enabled = true;
config.network.enable_socks5 = false;
config.network.enable_socks5_udp = false;
config.network.allow_upstream_proxy = false;
config.network.dangerously_allow_all_unix_sockets = true;
config.network.mode = NetworkMode::Limited;
config
.network
.set_allowed_domains(vec!["example.com".into()]);
config
.network
.set_denied_domains(vec!["blocked.example.com".into()]);
config
.network
.set_allow_unix_sockets(vec!["/var/run/example.sock".into()]);
config.network.allow_local_binding = true;
let remote =
RemoteNetworkProxyConfig::from_effective_config(&config).expect("supported remote config");
let round_trip = remote.into_network_proxy_config();
assert_eq!(round_trip, config);
}
#[test]
fn rejects_mitm_configuration() {
let mut config = NetworkProxyConfig::default();
config.network.mitm = true;
let error = RemoteNetworkProxyConfig::from_effective_config(&config)
.expect_err("MITM must not cross the remote executor boundary");
assert_eq!(
error.to_string(),
"remote exec-server network proxy does not support MITM, credential injection, or MITM hooks"
);
}
+29
View File
@@ -187,6 +187,26 @@ pub trait ConfigReloader: Send + Sync {
pub type ConfigReloaderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
#[derive(Clone)]
struct StaticConfigReloader {
state: ConfigState,
}
impl ConfigReloader for StaticConfigReloader {
fn source_label(&self) -> String {
"static config state".to_string()
}
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
Box::pin(async { Ok(None) })
}
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
let state = self.state.clone();
Box::pin(async move { Ok(state) })
}
}
pub trait BlockedRequestObserver: Send + Sync + 'static {
fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_>;
}
@@ -257,6 +277,15 @@ impl Clone for NetworkProxyState {
}
impl NetworkProxyState {
/// Builds immutable runtime state from an already-computed effective configuration.
pub fn from_static_config(config: NetworkProxyConfig) -> Result<Self> {
let state = build_config_state(config, NetworkProxyConstraints::default())?;
Ok(Self::with_reloader(
state.clone(),
Arc::new(StaticConfigReloader { state }),
))
}
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
Self::with_reloader_and_audit_metadata(
state,
@@ -269,6 +269,7 @@ fn prepared_managed_network_context_allows_only_its_proxy_ports() {
let managed_network = ManagedNetworkSandboxContext {
loopback_ports: vec![43123, 48081],
allow_local_binding: false,
proxy_config: None,
};
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command: vec!["/bin/true".to_string()],