codex: add exec-server managed-network follow-up

Split the remote managed-network wiring and verification fixes out of the main exec-server sandbox PR. This keeps the original review-response branch focused while carrying the exec-server-owned proxy startup, the unsupported approval-callback guard, and the added regression coverage in a stacked follow-up.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-04-07 11:37:49 -07:00
parent acb4d9abe0
commit 4a85ef45b5
15 changed files with 313 additions and 23 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2064,6 +2064,7 @@ dependencies = [
"clap",
"codex-app-server-protocol",
"codex-linux-sandbox",
"codex-network-proxy",
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",

View File

@@ -1279,6 +1279,18 @@ impl Session {
}
}
pub(crate) async fn managed_network_proxy_spec(
&self,
) -> Option<crate::config::NetworkProxySpec> {
let state = self.state.lock().await;
state
.session_configuration
.original_config_do_not_use
.permissions
.network
.clone()
}
/// Builds the `x-codex-beta-features` header value for this session.
///
/// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so

View File

@@ -78,6 +78,25 @@ impl NetworkProxySpec {
self.config.network.enabled
}
pub(crate) fn config(&self) -> &NetworkProxyConfig {
&self.config
}
pub(crate) fn constraints(&self) -> &NetworkProxyConstraints {
&self.constraints
}
pub(crate) fn requires_remote_approval_callbacks(
&self,
sandbox_policy: &SandboxPolicy,
) -> bool {
!self.hard_deny_allowlist_misses
&& matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
}
pub fn proxy_host_and_port(&self) -> String {
host_and_port_from_network_addr(&self.config.network.proxy_url, /*default_port*/ 3128)
}

View File

@@ -38,6 +38,32 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() {
assert_eq!(state.audit_metadata(), &metadata);
}
#[test]
fn requires_remote_approval_callbacks_only_for_restricted_expandable_managed_network() {
let expandable_spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
hard_deny_allowlist_misses: false,
};
assert!(
expandable_spec.requires_remote_approval_callbacks(&SandboxPolicy::new_read_only_policy())
);
assert!(
expandable_spec
.requires_remote_approval_callbacks(&SandboxPolicy::new_workspace_write_policy())
);
assert!(!expandable_spec.requires_remote_approval_callbacks(&SandboxPolicy::DangerFullAccess));
let hard_deny_spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
hard_deny_allowlist_misses: true,
};
assert!(
!hard_deny_spec.requires_remote_approval_callbacks(&SandboxPolicy::new_read_only_policy())
);
}
#[test]
fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() {
let mut config = NetworkProxyConfig::default();

View File

@@ -33,7 +33,9 @@ use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_exec_server::ManagedNetworkConfig;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::models::PermissionProfile;
@@ -83,6 +85,11 @@ pub struct UnifiedExecRuntime<'a> {
shell_mode: UnifiedExecShellMode,
}
struct RemoteManagedNetworkLaunch {
config: ManagedNetworkConfig,
requires_approval_callbacks: bool,
}
fn build_remote_exec_sandbox_config(
attempt: &SandboxAttempt<'_>,
additional_permissions: Option<PermissionProfile>,
@@ -105,6 +112,35 @@ fn build_remote_exec_sandbox_config(
}
}
async fn build_remote_exec_managed_network_launch(
session: &crate::codex::Session,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
) -> Option<RemoteManagedNetworkLaunch> {
let spec = session.managed_network_proxy_spec().await?;
let spec = spec
.with_exec_policy_network_rules(session.services.exec_policy.current().as_ref())
.map_err(|err| {
tracing::warn!(
"failed to apply execpolicy network rules to remote managed proxy; continuing with configured network policy: {err}"
);
err
})
.unwrap_or_else(|_| spec.clone());
Some(RemoteManagedNetworkLaunch {
config: ManagedNetworkConfig {
config: spec.config().clone(),
constraints: spec.constraints().clone(),
audit_metadata: NetworkProxyAuditMetadata {
conversation_id: Some(session.conversation_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
..NetworkProxyAuditMetadata::default()
},
},
requires_approval_callbacks: spec.requires_remote_approval_callbacks(sandbox_policy),
})
}
impl<'a> UnifiedExecRuntime<'a> {
/// Creates a runtime bound to the shared unified-exec process manager.
pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self {
@@ -238,10 +274,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
command
};
let mut env = req.env.clone();
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
}
let base_env = req.env.clone();
if let Some(environment) = ctx
.turn
.environment
@@ -254,17 +287,38 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.to_string(),
));
}
let managed_network = if req.network.is_some() {
let managed_network =
build_remote_exec_managed_network_launch(ctx.session.as_ref(), attempt.policy)
.await
.ok_or_else(|| {
ToolError::Rejected(
"remote unified_exec is missing managed-network proxy configuration"
.to_string(),
)
})?;
if managed_network.requires_approval_callbacks {
return Err(ToolError::Rejected(
"remote unified_exec does not yet support managed-network approval callbacks"
.to_string(),
));
}
Some(managed_network.config)
} else {
None
};
let exec_params = codex_exec_server::ExecParams {
process_id: req.process_id.to_string().into(),
argv: command,
cwd: req.cwd.clone(),
env,
env: base_env.clone(),
tty: req.tty,
arg0: None,
sandbox: build_remote_exec_sandbox_config(
attempt,
req.additional_permissions.clone(),
),
managed_network,
};
return self
.manager
@@ -280,6 +334,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
other => ToolError::Rejected(other.to_string()),
});
}
let mut env = base_env;
if let Some(network) = req.network.as_ref() {
network.apply_to_env(&mut env);
}
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),

View File

@@ -610,6 +610,7 @@ impl UnifiedExecProcessManager {
tty,
arg0: request.arg0.clone(),
sandbox: codex_sandboxing::SandboxLaunchConfig::no_sandbox(request.cwd.clone()),
managed_network: None,
})
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;

View File

@@ -20,6 +20,7 @@ arc-swap = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-app-server-protocol = { workspace = true }

View File

@@ -280,6 +280,7 @@ mod tests {
sandbox: SandboxLaunchConfig::no_sandbox(
std::env::current_dir().expect("read current dir"),
),
managed_network: None,
})
.await
.expect("start process");

View File

@@ -54,6 +54,7 @@ pub use protocol::ExecParams;
pub use protocol::ExecResponse;
pub use protocol::InitializeParams;
pub use protocol::InitializeResponse;
pub use protocol::ManagedNetworkConfig;
pub use protocol::ReadParams;
pub use protocol::ReadResponse;
pub use protocol::TerminateParams;

View File

@@ -9,6 +9,13 @@ use std::time::Duration;
use async_trait::async_trait;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyHandle;
use codex_network_proxy::NetworkProxyState;
use codex_network_proxy::build_config_state;
use codex_network_proxy::validate_policy_against_constraints;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxExecRequest;
use codex_sandboxing::SandboxType;
@@ -62,6 +69,7 @@ struct RetainedOutputChunk {
struct RunningProcess {
session: ExecCommandSession,
_managed_network: Option<ManagedNetworkRuntime>,
tty: bool,
output: VecDeque<RetainedOutputChunk>,
retained_bytes: usize,
@@ -122,6 +130,36 @@ struct StartedProcess {
wake_tx: watch::Sender<u64>,
}
struct ManagedNetworkRuntime {
proxy: NetworkProxy,
_handle: NetworkProxyHandle,
}
#[derive(Clone)]
struct StaticNetworkProxyReloader {
state: ConfigState,
}
#[async_trait]
impl ConfigReloader for StaticNetworkProxyReloader {
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
Ok(None)
}
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
Ok(self.state.clone())
}
fn source_label(&self) -> String {
"ExecServerStaticNetworkProxyReloader".to_string()
}
}
struct PreparedExecLaunch {
request: SandboxExecRequest,
managed_network: Option<ManagedNetworkRuntime>,
}
impl Default for LocalProcess {
fn default() -> Self {
let (outgoing_tx, mut outgoing_rx) =
@@ -197,8 +235,9 @@ impl LocalProcess {
async fn start_process(&self, params: ExecParams) -> Result<StartedProcess, JSONRPCErrorError> {
self.require_initialized_for("exec")?;
let process_id = params.process_id.clone();
let launch = prepare_exec_launch(&params, &self.inner.runtime)?;
let launch = prepare_exec_launch(&params, &self.inner.runtime).await?;
let (program, args) = launch
.request
.command
.split_first()
.ok_or_else(|| invalid_params("argv must not be empty".to_string()))?;
@@ -218,8 +257,8 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&launch.env,
&launch.arg0,
&launch.request.env,
&launch.request.arg0,
TerminalSize::default(),
)
.await
@@ -228,8 +267,8 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&launch.env,
&launch.arg0,
&launch.request.env,
&launch.request.arg0,
)
.await
};
@@ -252,6 +291,7 @@ impl LocalProcess {
process_id.clone(),
ProcessEntry::Running(Box::new(RunningProcess {
session: spawned.session,
_managed_network: launch.managed_network,
tty: params.tty,
output: VecDeque::new(),
retained_bytes: 0,
@@ -296,7 +336,7 @@ impl LocalProcess {
Ok(StartedProcess {
process_id,
sandbox_type: launch.sandbox,
sandbox_type: launch.request.sandbox,
wake_tx,
})
}
@@ -513,27 +553,70 @@ fn build_sandbox_command(
})
}
fn prepare_exec_launch(
async fn start_managed_network_runtime(
config: &crate::protocol::ManagedNetworkConfig,
) -> Result<ManagedNetworkRuntime, JSONRPCErrorError> {
validate_policy_against_constraints(&config.config, &config.constraints)
.map_err(|err| internal_error(format!("invalid managed network config: {err}")))?;
let state =
build_config_state(config.config.clone(), config.constraints.clone()).map_err(|err| {
internal_error(format!(
"failed to build managed network proxy state: {err}"
))
})?;
let reloader_state = state.clone();
let state = NetworkProxyState::with_reloader_and_audit_metadata(
state,
Arc::new(StaticNetworkProxyReloader {
state: reloader_state,
}),
config.audit_metadata.clone(),
);
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.build()
.await
.map_err(|err| internal_error(format!("failed to build managed network proxy: {err}")))?;
let handle = proxy
.run()
.await
.map_err(|err| internal_error(format!("failed to run managed network proxy: {err}")))?;
Ok(ManagedNetworkRuntime {
proxy,
_handle: handle,
})
}
async fn prepare_exec_launch(
params: &ExecParams,
runtime: &ExecServerRuntimeConfig,
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
) -> Result<PreparedExecLaunch, JSONRPCErrorError> {
let managed_network = match params.managed_network.as_ref() {
Some(config) => Some(start_managed_network_runtime(config).await?),
None => None,
};
let mut env = params.env.clone();
if let Some(network) = managed_network.as_ref() {
network.proxy.apply_to_env(&mut env);
}
let command = build_sandbox_command(
&params.argv,
params.cwd.as_path(),
&params.env,
&env,
params.sandbox.additional_permissions.clone(),
)?;
params
let request = params
.sandbox
.transform(
command,
// TODO: Thread managed-network proxy state across exec-server so
// sandbox profile generation preserves proxy-specific allowances.
/*network*/
None,
managed_network.as_ref().map(|network| &network.proxy),
runtime.codex_linux_sandbox_exe.as_ref(),
)
.map_err(|err| internal_error(format!("failed to build sandbox launch: {err}")))
.map_err(|err| internal_error(format!("failed to build sandbox launch: {err}")))?;
Ok(PreparedExecLaunch {
request,
managed_network,
})
}
impl LocalProcess {

View File

@@ -2,6 +2,9 @@ use std::collections::HashMap;
use std::path::PathBuf;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_sandboxing::SandboxLaunchConfig;
use serde::Deserialize;
use serde::Serialize;
@@ -51,6 +54,14 @@ pub struct InitializeParams {
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManagedNetworkConfig {
pub config: NetworkProxyConfig,
pub constraints: NetworkProxyConstraints,
pub audit_metadata: NetworkProxyAuditMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecParams {
@@ -63,6 +74,7 @@ pub struct ExecParams {
pub tty: bool,
pub arg0: Option<String>,
pub sandbox: SandboxLaunchConfig,
pub managed_network: Option<ManagedNetworkConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -31,6 +31,7 @@ fn exec_params(process_id: &str) -> ExecParams {
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(std::env::current_dir().expect("cwd")),
managed_network: None,
}
}

View File

@@ -9,9 +9,13 @@ use codex_exec_server::Environment;
use codex_exec_server::ExecBackend;
use codex_exec_server::ExecParams;
use codex_exec_server::ExecProcess;
use codex_exec_server::ManagedNetworkConfig;
use codex_exec_server::ProcessId;
use codex_exec_server::ReadResponse;
use codex_exec_server::StartedExecProcess;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -63,6 +67,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), "proc-1");
@@ -141,6 +146,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), process_id);
@@ -172,6 +178,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
tty: true,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
assert_eq!(session.process.process_id().as_str(), process_id);
@@ -210,6 +217,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(cwd),
managed_network: None,
})
.await?;
@@ -262,13 +270,32 @@ fn write_outside_workspace_sandbox(workspace_root: &std::path::Path) -> SandboxL
}
}
fn managed_network_config() -> ManagedNetworkConfig {
let mut config = NetworkProxyConfig::default();
config.network.enabled = true;
ManagedNetworkConfig {
config,
constraints: NetworkProxyConstraints {
enabled: Some(true),
..Default::default()
},
audit_metadata: NetworkProxyAuditMetadata {
conversation_id: Some("exec-server-smoke".to_string()),
..Default::default()
},
}
}
async fn assert_exec_process_sandbox_denies_write_outside_workspace(
use_remote: bool,
) -> Result<()> {
let temp_dir = TempDir::new()?;
let workspace_root = temp_dir.path().join("workspace");
std::fs::create_dir(&workspace_root)?;
let blocked_path = temp_dir.path().join("blocked.txt");
let blocked_dir = tempfile::Builder::new()
.prefix("exec-server-sandbox-blocked-")
.tempdir_in(std::env::current_dir()?)?;
let blocked_path = blocked_dir.path().join("blocked.txt");
let context = create_process_context(use_remote).await?;
let session = context
.backend
@@ -286,6 +313,7 @@ async fn assert_exec_process_sandbox_denies_write_outside_workspace(
tty: false,
arg0: None,
sandbox: write_outside_workspace_sandbox(&workspace_root),
managed_network: None,
})
.await?;
@@ -303,6 +331,44 @@ async fn assert_exec_process_sandbox_denies_write_outside_workspace(
Ok(())
}
async fn assert_remote_exec_process_applies_managed_network_proxy_env() -> Result<()> {
let context = create_process_context(/*use_remote*/ true).await?;
let session = context
.backend
.start(ExecParams {
process_id: ProcessId::from("proc-managed-network"),
argv: vec![
"/usr/bin/python3".to_string(),
"-c".to_string(),
"import os; print('HTTP_PROXY=' + os.environ['HTTP_PROXY']); print('HTTPS_PROXY=' + os.environ['HTTPS_PROXY'])"
.to_string(),
],
cwd: std::env::current_dir()?,
env: Default::default(),
tty: false,
arg0: None,
sandbox: SandboxLaunchConfig::no_sandbox(std::env::current_dir()?),
managed_network: Some(managed_network_config()),
})
.await?;
let StartedExecProcess { process, .. } = session;
let wake_rx = process.subscribe_wake();
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
assert!(
output.contains("HTTP_PROXY=http://127.0.0.1:"),
"expected HTTP proxy env from managed network runtime, got {output:?}"
);
assert!(
output.contains("HTTPS_PROXY=http://127.0.0.1:"),
"expected HTTPS proxy env from managed network runtime, got {output:?}"
);
assert_eq!(exit_code, Some(0));
assert!(closed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
let mut context = create_process_context(/*use_remote*/ true).await?;
@@ -322,6 +388,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
sandbox: SandboxLaunchConfig::no_sandbox(
std::env::current_dir().expect("read current dir"),
),
managed_network: None,
})
.await?;
@@ -381,3 +448,8 @@ async fn exec_process_preserves_queued_events_before_subscribe(use_remote: bool)
async fn remote_exec_process_sandbox_denies_write_outside_workspace() -> Result<()> {
assert_exec_process_sandbox_denies_write_outside_workspace(/*use_remote*/ true).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_exec_process_applies_managed_network_proxy_env() -> Result<()> {
assert_remote_exec_process_applies_managed_network_proxy_env().await
}

View File

@@ -19,6 +19,7 @@ use anyhow::Result;
use async_trait::async_trait;
use codex_utils_absolute_path::AbsolutePathBuf;
use globset::GlobSet;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashSet;
use std::collections::VecDeque;
@@ -39,7 +40,7 @@ const MAX_BLOCKED_EVENTS: usize = 200;
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,

View File

@@ -9,6 +9,7 @@ use crate::policy::compile_denylist_globset;
use crate::policy::is_global_wildcard_domain_pattern;
use crate::runtime::ConfigState;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashSet;
use std::sync::Arc;
@@ -19,7 +20,7 @@ pub use crate::runtime::NetworkProxyState;
#[cfg(test)]
pub(crate) use crate::runtime::network_proxy_state_for_policy;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkProxyConstraints {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,