mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Launch managed network proxies on remote executors (#33906)
## Why Remote executions need managed-network proxy listeners in the executor so their loopback proxy addresses are reachable by the launched process. ## What changed - Add a capability-gated exec-server protocol field for executor-local proxy launch configuration, including network policy, audit metadata, and execution attribution. - Start the proxy while preparing a remote process, replace inherited proxy environment variables with its local addresses, and derive the sandbox context from its listeners. - Keep the proxy alive until inherited output streams close, then shut it down. - Reject unsupported remote settings such as MITM and credential injection. ## Testing - Cover configuration round trips and rejection of unsupported settings. - Verify executor-local startup, blocked-domain enforcement, protocol compatibility, and proxy lifetime through process closure. GitOrigin-RevId: c984f54e3e600aa9ebcbf8cf4574046e2c199d11
This commit is contained in:
@@ -461,6 +461,7 @@ pub(crate) async fn execute_exec_request(
|
||||
exec_server_sandbox: _,
|
||||
exec_server_enforce_managed_network: _,
|
||||
exec_server_managed_network: _,
|
||||
exec_server_network_proxy: _,
|
||||
} = exec_request;
|
||||
|
||||
// TODO(anp): Keep PathUri through the local process launch boundary.
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -65,6 +66,7 @@ pub struct ExecRequest {
|
||||
pub(crate) exec_server_sandbox: Option<FileSystemSandboxContext>,
|
||||
pub(crate) exec_server_enforce_managed_network: bool,
|
||||
pub(crate) exec_server_managed_network: Option<ManagedNetworkSandboxContext>,
|
||||
pub(crate) exec_server_network_proxy: Option<RemoteNetworkProxyLaunchConfig>,
|
||||
}
|
||||
|
||||
impl ExecRequest {
|
||||
@@ -110,6 +112,7 @@ impl ExecRequest {
|
||||
exec_server_sandbox: None,
|
||||
exec_server_enforce_managed_network: false,
|
||||
exec_server_managed_network: None,
|
||||
exec_server_network_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +172,7 @@ impl ExecRequest {
|
||||
exec_server_sandbox: None,
|
||||
exec_server_enforce_managed_network: false,
|
||||
exec_server_managed_network: None,
|
||||
exec_server_network_proxy: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
exec_server_sandbox: None,
|
||||
exec_server_enforce_managed_network: false,
|
||||
exec_server_managed_network: None,
|
||||
exec_server_network_proxy: None,
|
||||
};
|
||||
|
||||
let stdout_stream = Some(StdoutStream {
|
||||
|
||||
@@ -27,7 +27,6 @@ use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -859,7 +858,6 @@ pub(crate) async fn begin_network_approval(
|
||||
session: &Session,
|
||||
turn_id: &str,
|
||||
managed_network_active: bool,
|
||||
selected_sandbox: SandboxType,
|
||||
spec: Option<NetworkApprovalSpec>,
|
||||
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
|
||||
let NetworkApprovalSpec {
|
||||
@@ -880,18 +878,14 @@ pub(crate) async fn begin_network_approval(
|
||||
}
|
||||
|
||||
let registration_id = Uuid::new_v4().to_string();
|
||||
let execution_proxy = if selected_sandbox == SandboxType::LinuxSeccomp {
|
||||
let attribution_token = Uuid::new_v4().to_string();
|
||||
network
|
||||
.for_execution(&environment_id, ®istration_id, attribution_token)
|
||||
.map_err(|err| {
|
||||
ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other(
|
||||
format!("failed to create execution-scoped network proxy: {err}"),
|
||||
)))
|
||||
})?
|
||||
} else {
|
||||
network.clone()
|
||||
};
|
||||
let attribution_token = Uuid::new_v4().to_string();
|
||||
let execution_proxy = network
|
||||
.for_execution(&environment_id, ®istration_id, attribution_token)
|
||||
.map_err(|err| {
|
||||
ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other(
|
||||
format!("failed to create execution-scoped network proxy: {err}"),
|
||||
)))
|
||||
})?;
|
||||
let cancellation_token = CancellationToken::new();
|
||||
session
|
||||
.services
|
||||
|
||||
@@ -67,7 +67,6 @@ impl ToolOrchestrator {
|
||||
&tool_ctx.session,
|
||||
&tool_ctx.turn.sub_id,
|
||||
managed_network_active,
|
||||
attempt.sandbox,
|
||||
tool.network_approval_spec(req, tool_ctx),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -169,6 +169,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
exec_server_sandbox: _,
|
||||
exec_server_enforce_managed_network: _,
|
||||
exec_server_managed_network: _,
|
||||
exec_server_network_proxy: _,
|
||||
} = sandbox_exec_request;
|
||||
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
|
||||
let effective_timeout = Duration::from_millis(
|
||||
@@ -904,6 +905,7 @@ impl CoreShellCommandExecutor {
|
||||
exec_server_sandbox: None,
|
||||
exec_server_enforce_managed_network: false,
|
||||
exec_server_managed_network: None,
|
||||
exec_server_network_proxy: None,
|
||||
},
|
||||
/*stdout_stream*/ None,
|
||||
after_spawn,
|
||||
|
||||
@@ -328,7 +328,33 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
launch_sandbox_permissions,
|
||||
));
|
||||
let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
|
||||
let (env, managed_network_context) = match managed_network {
|
||||
let (env, managed_network_context, network_proxy_launch) = match managed_network {
|
||||
Some(network) if environment_is_remote => {
|
||||
let launch = network.remote_launch_config().await.map_err(|err| {
|
||||
ToolError::Codex(CodexErr::Io(io::Error::other(err.to_string())))
|
||||
})?;
|
||||
if !launch.proxy.enabled {
|
||||
(env, None, None)
|
||||
} else {
|
||||
let environment_info =
|
||||
req.turn_environment
|
||||
.environment
|
||||
.info()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ToolError::Codex(CodexErr::Io(io::Error::other(format!(
|
||||
"failed to query exec-server capabilities: {err}"
|
||||
))))
|
||||
})?;
|
||||
if !environment_info.capabilities.network_proxy_launch {
|
||||
return Err(ToolError::Rejected(
|
||||
"selected exec-server does not support executor-local network proxy launches"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
(env, None, Some(launch))
|
||||
}
|
||||
}
|
||||
Some(network) => {
|
||||
let prepared = network
|
||||
.prepare_for_optional_environment(
|
||||
@@ -341,9 +367,9 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
req.turn_environment.environment_id
|
||||
))))
|
||||
})?;
|
||||
(prepared.env, Some(prepared.sandbox_context))
|
||||
(prepared.env, Some(prepared.sandbox_context), None)
|
||||
}
|
||||
None => (env, None),
|
||||
None => (env, None, None),
|
||||
};
|
||||
let explicit_env_overrides = req.explicit_env_overrides.clone();
|
||||
#[cfg(unix)]
|
||||
@@ -480,6 +506,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
options,
|
||||
attempt,
|
||||
managed_network,
|
||||
network_proxy_launch,
|
||||
/*environment_id*/ Some(&req.turn_environment.environment_id),
|
||||
req.exec_server_env_config.clone(),
|
||||
req.tty,
|
||||
|
||||
@@ -474,10 +474,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,
|
||||
@@ -491,8 +488,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,
|
||||
|
||||
@@ -246,7 +246,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 +278,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);
|
||||
|
||||
@@ -193,6 +193,7 @@ fn exec_server_params_for_request(
|
||||
sandbox: request.exec_server_sandbox.clone(),
|
||||
enforce_managed_network: request.exec_server_enforce_managed_network,
|
||||
managed_network: request.exec_server_managed_network.clone(),
|
||||
network_proxy: request.exec_server_network_proxy.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +960,7 @@ impl UnifiedExecProcessManager {
|
||||
options: ExecOptions,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
network: Option<&NetworkProxy>,
|
||||
network_proxy_launch: Option<codex_network_proxy::RemoteNetworkProxyLaunchConfig>,
|
||||
environment_id: Option<&str>,
|
||||
exec_server_env_config: Option<ExecServerEnvConfig>,
|
||||
tty: bool,
|
||||
@@ -966,11 +968,12 @@ 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)
|
||||
}
|
||||
.map_err(ToolError::Codex)?;
|
||||
request.exec_server_network_proxy = network_proxy_launch;
|
||||
request.exec_server_env_config = exec_server_env_config;
|
||||
self.open_session_with_prepared_exec_env(
|
||||
process_id,
|
||||
|
||||
@@ -174,6 +174,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
|
||||
exec_server_sandbox: None,
|
||||
exec_server_enforce_managed_network: true,
|
||||
exec_server_managed_network: Some(managed_network.clone()),
|
||||
exec_server_network_proxy: None,
|
||||
};
|
||||
|
||||
let params =
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_file_system::FileSystemSandboxContext;
|
||||
pub use codex_file_system::WalkOptions;
|
||||
pub use codex_file_system::WalkOutcome;
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
|
||||
use codex_shell_command::shell_detect::DetectedShell;
|
||||
@@ -92,6 +93,18 @@ pub struct EnvironmentInfo {
|
||||
/// Working directory inherited by the exec-server process.
|
||||
#[serde(default)]
|
||||
pub cwd: Option<PathUri>,
|
||||
/// Optional executor features that clients must gate before sending newer request fields.
|
||||
#[serde(default)]
|
||||
pub capabilities: EnvironmentCapabilities,
|
||||
}
|
||||
|
||||
/// Features supported by the selected exec-server environment.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentCapabilities {
|
||||
/// Whether `exec` accepts instructions for launching an executor-local network proxy.
|
||||
#[serde(default)]
|
||||
pub network_proxy_launch: bool,
|
||||
}
|
||||
|
||||
/// Status returned by an initialized exec-server connection.
|
||||
@@ -121,6 +134,9 @@ impl EnvironmentInfo {
|
||||
cwd: std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| PathUri::from_host_native_path(cwd).ok()),
|
||||
capabilities: EnvironmentCapabilities {
|
||||
network_proxy_launch: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +192,9 @@ pub struct ExecParams {
|
||||
/// continue to fail closed. This preserves compatibility with older clients.
|
||||
#[serde(default)]
|
||||
pub managed_network: Option<ManagedNetworkSandboxContext>,
|
||||
/// Optional instructions for starting an executor-local managed-network proxy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub network_proxy: Option<RemoteNetworkProxyLaunchConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -710,6 +729,7 @@ mod base64_bytes {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EnvironmentCapabilities;
|
||||
use super::EnvironmentInfo;
|
||||
use super::ExecExitedNotification;
|
||||
use super::ExecParams;
|
||||
@@ -719,6 +739,10 @@ mod tests {
|
||||
use super::ShellInfo;
|
||||
use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_network_proxy::ManagedNetworkSandboxContext;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::RemoteNetworkProxyConfig;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -730,7 +754,7 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn exec_params_managed_network_context_round_trips_and_defaults_for_legacy_peers() {
|
||||
fn exec_params_keeps_proxy_launch_separate_from_sandbox_facts() {
|
||||
let cwd =
|
||||
PathUri::from_host_native_path(std::env::current_dir().expect("current directory"))
|
||||
.expect("cwd URI");
|
||||
@@ -749,6 +773,17 @@ mod tests {
|
||||
loopback_ports: vec![43123, 48081],
|
||||
allow_local_binding: false,
|
||||
}),
|
||||
network_proxy: Some(
|
||||
RemoteNetworkProxyLaunchConfig::new(
|
||||
RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig::default())
|
||||
.expect("supported remote config"),
|
||||
)
|
||||
.with_audit_metadata(NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("conversation-1".to_string()),
|
||||
..NetworkProxyAuditMetadata::default()
|
||||
})
|
||||
.for_execution("remote".to_string(), "execution-1".to_string()),
|
||||
),
|
||||
};
|
||||
|
||||
let mut serialized = serde_json::to_value(¶ms).expect("serialize exec params");
|
||||
@@ -759,6 +794,10 @@ mod tests {
|
||||
"allowLocalBinding": false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serialized["networkProxy"]["auditMetadata"]["conversationId"],
|
||||
"conversation-1"
|
||||
);
|
||||
let round_trip: ExecParams =
|
||||
serde_json::from_value(serialized.clone()).expect("deserialize exec params");
|
||||
assert_eq!(round_trip, params);
|
||||
@@ -767,10 +806,18 @@ mod tests {
|
||||
.as_object_mut()
|
||||
.expect("exec params object")
|
||||
.remove("managedNetwork");
|
||||
serialized
|
||||
.as_object_mut()
|
||||
.expect("exec params object")
|
||||
.remove("networkProxy");
|
||||
let legacy: ExecParams =
|
||||
serde_json::from_value(serialized).expect("deserialize legacy exec params");
|
||||
assert!(legacy.enforce_managed_network);
|
||||
assert_eq!(legacy.managed_network, None);
|
||||
assert_eq!(legacy.network_proxy, None);
|
||||
let legacy_serialized =
|
||||
serde_json::to_value(&legacy).expect("serialize exec params without proxy launch");
|
||||
assert!(legacy_serialized.get("networkProxy").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -788,6 +835,7 @@ mod tests {
|
||||
path: "/bin/zsh".to_string(),
|
||||
},
|
||||
cwd: None,
|
||||
capabilities: EnvironmentCapabilities::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1681,6 +1681,7 @@ mod tests {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.instrument(parent_span)
|
||||
.await
|
||||
|
||||
@@ -1459,6 +1459,7 @@ mod tests {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await
|
||||
.expect("start process");
|
||||
@@ -1499,6 +1500,7 @@ mod tests {
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await;
|
||||
let Err(err) = result else {
|
||||
|
||||
@@ -112,6 +112,7 @@ pub use protocol::CapabilityRootsDiscoverResponse;
|
||||
pub use protocol::CapabilityTextFile;
|
||||
pub use protocol::DiscoveredPluginFiles;
|
||||
pub use protocol::DiscoveredSkillFiles;
|
||||
pub use protocol::EnvironmentCapabilities;
|
||||
pub use protocol::EnvironmentInfo;
|
||||
pub use protocol::EnvironmentStatus;
|
||||
pub use protocol::EnvironmentStatusKind;
|
||||
|
||||
@@ -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;
|
||||
@@ -100,6 +101,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.
|
||||
@@ -238,7 +240,7 @@ impl LocalProcess {
|
||||
) -> Result<(ExecResponse, watch::Sender<u64>, ExecProcessEventLog), JSONRPCErrorError> {
|
||||
let process_id = params.process_id.clone();
|
||||
let prepared =
|
||||
prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?;
|
||||
prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref()).await?;
|
||||
let (program, args) = prepared
|
||||
.command
|
||||
.split_first()
|
||||
@@ -341,6 +343,7 @@ impl LocalProcess {
|
||||
termination_requested: false,
|
||||
sandbox: prepared.sandbox,
|
||||
sandbox_denied: false,
|
||||
network_proxy_handle: prepared.network_proxy_handle,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -924,7 +927,7 @@ async fn finish_output_stream(process_id: ProcessId, inner: Arc<Inner>) {
|
||||
}
|
||||
|
||||
async fn maybe_emit_closed(process_id: ProcessId, inner: Arc<Inner>) {
|
||||
let (notification, output_notify) = {
|
||||
let (notification, output_notify, network_proxy_handle) = {
|
||||
let mut processes = inner.processes.lock().await;
|
||||
let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) else {
|
||||
return;
|
||||
@@ -945,9 +948,16 @@ async fn maybe_emit_closed(process_id: ProcessId, inner: Arc<Inner>) {
|
||||
seq,
|
||||
},
|
||||
Arc::clone(&process.output_notify),
|
||||
process.network_proxy_handle.take(),
|
||||
)
|
||||
};
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
output_notify.notify_waiters();
|
||||
let cleanup_process_id = process_id.clone();
|
||||
let cleanup_inner = Arc::clone(&inner);
|
||||
@@ -985,6 +995,11 @@ mod tests {
|
||||
use codex_exec_server_protocol::JSONRPCMessage;
|
||||
use codex_exec_server_protocol::JSONRPCResponse;
|
||||
use codex_exec_server_protocol::RequestId;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyState;
|
||||
use codex_network_proxy::RemoteNetworkProxyConfig;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_otel::MetricsConfig;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
@@ -1010,6 +1025,7 @@ mod tests {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1327,6 +1343,68 @@ mod tests {
|
||||
backend.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exited_process_keeps_network_proxy_until_inherited_streams_close() {
|
||||
let backend = LocalProcess::default();
|
||||
let mut process = spawn_test_process(&backend, "proc-background-child").await;
|
||||
let config = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
let proxy_config = RemoteNetworkProxyConfig::from_effective_config(&config)
|
||||
.expect("build remote network proxy config");
|
||||
let state = NetworkProxyState::from_remote_launch_config(
|
||||
RemoteNetworkProxyLaunchConfig::new(proxy_config),
|
||||
)
|
||||
.expect("build network proxy state");
|
||||
let proxy = NetworkProxy::builder()
|
||||
.state(Arc::new(state))
|
||||
.build()
|
||||
.await
|
||||
.expect("build network proxy");
|
||||
let handle = proxy.run().await.expect("start network proxy");
|
||||
let prepared = proxy
|
||||
.prepare_for_optional_environment(HashMap::new(), /*environment_id*/ None)
|
||||
.expect("prepare network proxy environment");
|
||||
let proxy_addr: std::net::SocketAddr = prepared
|
||||
.env
|
||||
.get("HTTP_PROXY")
|
||||
.and_then(|value| value.strip_prefix("http://"))
|
||||
.expect("HTTP proxy address")
|
||||
.parse()
|
||||
.expect("parse HTTP proxy address");
|
||||
|
||||
{
|
||||
let mut processes = backend.inner.processes.lock().await;
|
||||
let Some(ProcessEntry::Running(running)) = processes.get_mut(&process.process_id)
|
||||
else {
|
||||
panic!("test process should be running");
|
||||
};
|
||||
running.network_proxy_handle = Some(handle);
|
||||
}
|
||||
|
||||
process.exit(/*exit_code*/ 0);
|
||||
let exit_response =
|
||||
read_process_until_change(&backend, &process.process_id, /*after_seq*/ None).await;
|
||||
assert!(exit_response.exited);
|
||||
assert!(!exit_response.closed);
|
||||
tokio::net::TcpStream::connect(proxy_addr)
|
||||
.await
|
||||
.expect("proxy should remain available to a child holding inherited output streams");
|
||||
|
||||
drop(process.stdout_tx);
|
||||
drop(process.stderr_tx);
|
||||
let closed_response = timeout(
|
||||
Duration::from_secs(1),
|
||||
read_process_until_closed(&backend, &process.process_id),
|
||||
)
|
||||
.await
|
||||
.expect("process should close");
|
||||
assert!(closed_response.closed);
|
||||
assert!(tokio::net::TcpStream::connect(proxy_addr).await.is_err());
|
||||
backend.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_process_is_evicted_after_retention() {
|
||||
let backend = LocalProcess::default();
|
||||
@@ -1411,6 +1489,7 @@ mod tests {
|
||||
termination_requested: false,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_denied: false,
|
||||
network_proxy_handle: None,
|
||||
})),
|
||||
);
|
||||
assert!(previous.is_none());
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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::RemoteNetworkProxyLaunchConfig;
|
||||
use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
@@ -16,6 +22,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 +31,20 @@ 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(),
|
||||
params.network_proxy.as_ref(),
|
||||
env,
|
||||
)
|
||||
.await?;
|
||||
let Some(sandbox_context) = params.sandbox.as_ref() else {
|
||||
return Ok(PreparedExecRequest {
|
||||
command: params.argv.clone(),
|
||||
@@ -38,6 +52,7 @@ pub(crate) fn prepare_exec_request(
|
||||
env,
|
||||
arg0: params.arg0.clone(),
|
||||
sandbox: SandboxType::None,
|
||||
network_proxy_handle,
|
||||
});
|
||||
};
|
||||
let runtime_paths = runtime_paths
|
||||
@@ -58,7 +73,7 @@ pub(crate) fn prepare_exec_request(
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let workspace_roots = 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) {
|
||||
@@ -129,7 +144,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,
|
||||
@@ -151,9 +166,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>,
|
||||
network_proxy: Option<&RemoteNetworkProxyLaunchConfig>,
|
||||
env: HashMap<String, String>,
|
||||
) -> Result<
|
||||
(
|
||||
HashMap<String, String>,
|
||||
Option<ManagedNetworkSandboxContext>,
|
||||
Option<NetworkProxyHandle>,
|
||||
),
|
||||
JSONRPCErrorError,
|
||||
> {
|
||||
let Some(network_proxy) = network_proxy.cloned() else {
|
||||
return Ok((env, managed_network.cloned(), None));
|
||||
};
|
||||
let state = NetworkProxyState::from_remote_launch_config(network_proxy)
|
||||
.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,20 @@
|
||||
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;
|
||||
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
||||
#[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 +25,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()
|
||||
@@ -47,9 +55,11 @@ fn sandbox_request_wraps_native_argv_on_executor() {
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
};
|
||||
|
||||
let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths))
|
||||
.await
|
||||
.expect("prepare sandboxed request");
|
||||
|
||||
assert_ne!(prepared.command, params.argv);
|
||||
@@ -82,8 +92,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()
|
||||
@@ -111,9 +121,11 @@ fn sandbox_request_allows_prepared_managed_proxy_port() {
|
||||
loopback_ports: vec![43123],
|
||||
allow_local_binding: false,
|
||||
}),
|
||||
network_proxy: None,
|
||||
};
|
||||
|
||||
let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths))
|
||||
.await
|
||||
.expect("prepare managed-network sandbox request");
|
||||
let policy = prepared
|
||||
.command
|
||||
@@ -124,8 +136,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()
|
||||
@@ -144,9 +156,11 @@ fn native_request_preserves_native_launch_fields() {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
};
|
||||
|
||||
let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None)
|
||||
.await
|
||||
.expect("prepare native request");
|
||||
|
||||
assert_eq!(prepared.command, params.argv);
|
||||
@@ -154,3 +168,107 @@ 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 {
|
||||
enabled: true,
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
config.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: None,
|
||||
network_proxy: Some(
|
||||
RemoteNetworkProxyLaunchConfig::new(proxy_config)
|
||||
.for_execution("remote".to_string(), "execution-1".to_string()),
|
||||
),
|
||||
};
|
||||
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(¶ms, 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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_remote_proxy_config_is_rejected_before_exporting_ports() {
|
||||
let cwd: AbsolutePathBuf = std::env::current_dir()
|
||||
.expect("current directory")
|
||||
.try_into()
|
||||
.expect("absolute cwd");
|
||||
let proxy_config =
|
||||
RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig::default())
|
||||
.expect("serializable disabled proxy config");
|
||||
let params = ExecParams {
|
||||
process_id: ProcessId::from("process-disabled-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: None,
|
||||
network_proxy: Some(RemoteNetworkProxyLaunchConfig::new(proxy_config)),
|
||||
};
|
||||
|
||||
let error = prepare_exec_request(¶ms, HashMap::new(), /*runtime_paths*/ None)
|
||||
.await
|
||||
.err()
|
||||
.expect("disabled executor proxy launch must fail closed");
|
||||
|
||||
assert_eq!(error.code, -32602);
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("executor-local network proxy launch requires an enabled proxy")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec<String>) -> ExecParams {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -604,6 +604,7 @@ mod tests {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), "proc-1");
|
||||
@@ -152,7 +153,7 @@ async fn remote_process_keeps_sandbox_helper_visible_with_restricted_reads() ->
|
||||
process_id: ProcessId::from("proc-restricted-helper"),
|
||||
argv: vec!["/bin/cat".to_string(), file.to_string_lossy().into_owned()],
|
||||
cwd,
|
||||
env_policy: None,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: HashMap::from([("PATH".to_string(), std::env::var("PATH")?)]),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
@@ -160,6 +161,7 @@ async fn remote_process_keeps_sandbox_helper_visible_with_restricted_reads() ->
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
let output = collect_process_output_from_events(session.process).await?;
|
||||
@@ -218,7 +220,7 @@ async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() -
|
||||
process_id: ProcessId::from("proc-hostile-helper-path"),
|
||||
argv: vec!["/bin/cat".to_string(), file.to_string_lossy().into_owned()],
|
||||
cwd,
|
||||
env_policy: None,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: HashMap::from([(
|
||||
"PATH".to_string(),
|
||||
hostile_path.to_string_lossy().into_owned(),
|
||||
@@ -229,6 +231,7 @@ async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() -
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
let output = collect_process_output_from_events(session.process).await?;
|
||||
@@ -279,6 +282,7 @@ async fn remote_process_preserves_empty_workspace_roots() -> Result<()> {
|
||||
sandbox: Some(sandbox),
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
let (stdout, _stderr, exit_code, closed) =
|
||||
@@ -423,6 +427,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -457,6 +462,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -507,6 +513,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -558,6 +565,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close(
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -634,6 +642,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -674,6 +683,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -710,6 +720,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool)
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -747,6 +758,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
@@ -803,6 +815,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -846,6 +859,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -907,6 +921,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()>
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -323,6 +323,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -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,8 @@ 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 remote_config::RemoteNetworkProxyLaunchConfig;
|
||||
pub use runtime::BlockedRequest;
|
||||
pub use runtime::BlockedRequestArgs;
|
||||
pub use runtime::BlockedRequestObserver;
|
||||
|
||||
@@ -650,6 +650,29 @@ impl NetworkProxy {
|
||||
self.state.current_cfg().await
|
||||
}
|
||||
|
||||
/// Captures the static inputs needed to launch a matching executor-local proxy.
|
||||
pub async fn remote_launch_config(&self) -> Result<crate::RemoteNetworkProxyLaunchConfig> {
|
||||
let proxy = crate::RemoteNetworkProxyConfig::from_effective_config(
|
||||
&self.state.current_cfg().await?,
|
||||
)?;
|
||||
let (environment_id, execution_id) = self
|
||||
.execution_scope
|
||||
.as_ref()
|
||||
.map(|scope| {
|
||||
(
|
||||
Some(scope.environment_id.clone()),
|
||||
Some(scope.execution_id.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(crate::RemoteNetworkProxyLaunchConfig {
|
||||
proxy,
|
||||
audit_metadata: self.state.audit_metadata().clone(),
|
||||
environment_id,
|
||||
execution_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
|
||||
self.state.add_allowed_domain(host).await
|
||||
}
|
||||
@@ -1218,6 +1241,30 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_launch_config_carries_execution_scope() -> Result<()> {
|
||||
let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig::default()));
|
||||
let proxy = match NetworkProxy::builder().state(state).build().await {
|
||||
Ok(proxy) => proxy,
|
||||
Err(err) => {
|
||||
if err
|
||||
.chain()
|
||||
.any(|cause| cause.to_string().contains("Operation not permitted"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let scoped = proxy.for_execution("remote-env", "execution-1", "token-1".to_string())?;
|
||||
let launch = scoped.remote_launch_config().await?;
|
||||
|
||||
assert_eq!(launch.environment_id.as_deref(), Some("remote-env"));
|
||||
assert_eq!(launch.execution_id.as_deref(), Some("execution-1"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
|
||||
let settings = NetworkProxyConfig {
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
pub(super) struct ExecutionScope {
|
||||
pub(super) environment_id: String,
|
||||
pub(super) execution_id: String,
|
||||
pub(super) attribution_token: String,
|
||||
state: Arc<NetworkProxyState>,
|
||||
}
|
||||
@@ -30,6 +31,7 @@ impl NetworkProxy {
|
||||
let mut proxy = self.clone();
|
||||
proxy.execution_scope = Some(Arc::new(ExecutionScope {
|
||||
environment_id: environment_id.to_string(),
|
||||
execution_id: execution_id.to_string(),
|
||||
attribution_token,
|
||||
state: Arc::clone(&self.state),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::ensure;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::NetworkDomainPermissions;
|
||||
use crate::NetworkMode;
|
||||
use crate::NetworkProxyAuditMetadata;
|
||||
use crate::NetworkProxyConfig;
|
||||
use crate::NetworkUnixSocketPermissions;
|
||||
|
||||
/// Executor-local proxy launch inputs transported with one process start.
|
||||
///
|
||||
/// Unlike [`crate::ManagedNetworkSandboxContext`], this describes how the executor should create
|
||||
/// proxy listeners. The sandbox context is materialized only after those listeners are running.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[non_exhaustive]
|
||||
pub struct RemoteNetworkProxyLaunchConfig {
|
||||
pub proxy: RemoteNetworkProxyConfig,
|
||||
#[serde(default)]
|
||||
pub audit_metadata: NetworkProxyAuditMetadata,
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub execution_id: Option<String>,
|
||||
}
|
||||
|
||||
impl RemoteNetworkProxyLaunchConfig {
|
||||
pub fn new(proxy: RemoteNetworkProxyConfig) -> Self {
|
||||
Self {
|
||||
proxy,
|
||||
audit_metadata: NetworkProxyAuditMetadata::default(),
|
||||
environment_id: None,
|
||||
execution_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_audit_metadata(mut self, audit_metadata: NetworkProxyAuditMetadata) -> Self {
|
||||
self.audit_metadata = audit_metadata;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn for_execution(mut self, environment_id: String, execution_id: String) -> Self {
|
||||
self.environment_id = Some(environment_id);
|
||||
self.execution_id = Some(execution_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
#[non_exhaustive]
|
||||
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> {
|
||||
ensure!(
|
||||
!config.enabled
|
||||
|| (!config.mitm
|
||||
&& !config.credential_broker
|
||||
&& !config.dangerously_allow_plaintext_credential_injection
|
||||
&& config.mitm_hooks.is_empty()),
|
||||
"remote exec-server network proxy does not support MITM, credential injection, or MITM hooks"
|
||||
);
|
||||
Ok(Self {
|
||||
enabled: config.enabled,
|
||||
enable_socks5: config.enable_socks5,
|
||||
enable_socks5_udp: config.enable_socks5_udp,
|
||||
allow_upstream_proxy: config.allow_upstream_proxy,
|
||||
dangerously_allow_all_unix_sockets: config.dangerously_allow_all_unix_sockets,
|
||||
mode: config.mode,
|
||||
domains: config.domains.clone(),
|
||||
unix_sockets: config.unix_sockets.clone(),
|
||||
allow_local_binding: config.allow_local_binding,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn into_network_proxy_config(self) -> NetworkProxyConfig {
|
||||
NetworkProxyConfig {
|
||||
enabled: self.enabled,
|
||||
enable_socks5: self.enable_socks5,
|
||||
enable_socks5_udp: self.enable_socks5_udp,
|
||||
allow_upstream_proxy: self.allow_upstream_proxy,
|
||||
dangerously_allow_all_unix_sockets: self.dangerously_allow_all_unix_sockets,
|
||||
mode: self.mode,
|
||||
domains: self.domains,
|
||||
unix_sockets: self.unix_sockets,
|
||||
allow_local_binding: self.allow_local_binding,
|
||||
..NetworkProxyConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "remote_config_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,120 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::RemoteNetworkProxyConfig;
|
||||
use super::RemoteNetworkProxyLaunchConfig;
|
||||
use crate::MitmHookConfig;
|
||||
use crate::NetworkMode;
|
||||
use crate::NetworkProxyAuditMetadata;
|
||||
use crate::NetworkProxyConfig;
|
||||
use crate::NetworkProxyState;
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_supported_effective_settings() {
|
||||
let mut config = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
enable_socks5: false,
|
||||
enable_socks5_udp: false,
|
||||
allow_upstream_proxy: false,
|
||||
dangerously_allow_all_unix_sockets: true,
|
||||
mode: NetworkMode::Limited,
|
||||
allow_local_binding: true,
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
config.set_allowed_domains(vec!["example.com".into()]);
|
||||
config.set_denied_domains(vec!["blocked.example.com".into()]);
|
||||
config.set_allow_unix_sockets(vec!["/var/run/example.sock".into()]);
|
||||
|
||||
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_unsupported_configuration() {
|
||||
let cases = [
|
||||
(
|
||||
"MITM",
|
||||
NetworkProxyConfig {
|
||||
enabled: true,
|
||||
mitm: true,
|
||||
..NetworkProxyConfig::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"credential broker",
|
||||
NetworkProxyConfig {
|
||||
enabled: true,
|
||||
credential_broker: true,
|
||||
..NetworkProxyConfig::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"plaintext credential injection",
|
||||
NetworkProxyConfig {
|
||||
enabled: true,
|
||||
dangerously_allow_plaintext_credential_injection: true,
|
||||
..NetworkProxyConfig::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"MITM hooks",
|
||||
NetworkProxyConfig {
|
||||
enabled: true,
|
||||
mitm_hooks: vec![MitmHookConfig::default()],
|
||||
..NetworkProxyConfig::default()
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
for (feature, config) in cases {
|
||||
assert!(
|
||||
RemoteNetworkProxyConfig::from_effective_config(&config).is_err(),
|
||||
"{feature} must not cross the remote executor boundary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_unsupported_configuration_when_proxy_is_disabled() {
|
||||
let config = NetworkProxyConfig {
|
||||
mitm: true,
|
||||
credential_broker: true,
|
||||
dangerously_allow_plaintext_credential_injection: true,
|
||||
mitm_hooks: vec![MitmHookConfig::default()],
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
|
||||
let remote = RemoteNetworkProxyConfig::from_effective_config(&config)
|
||||
.expect("disabled proxy configuration does not cross the executor boundary");
|
||||
|
||||
assert!(!remote.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_config_materializes_audit_and_execution_attribution() {
|
||||
let proxy = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig {
|
||||
enabled: true,
|
||||
..NetworkProxyConfig::default()
|
||||
})
|
||||
.expect("supported remote config");
|
||||
let audit_metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("conversation-1".to_string()),
|
||||
user_account_id: Some("account-1".to_string()),
|
||||
originator: Some("codex_cli_rs".to_string()),
|
||||
model: Some("model-1".to_string()),
|
||||
..NetworkProxyAuditMetadata::default()
|
||||
};
|
||||
let state = NetworkProxyState::from_remote_launch_config(RemoteNetworkProxyLaunchConfig {
|
||||
proxy,
|
||||
audit_metadata: audit_metadata.clone(),
|
||||
environment_id: Some("remote".to_string()),
|
||||
execution_id: Some("execution-1".to_string()),
|
||||
})
|
||||
.expect("remote launch state");
|
||||
|
||||
assert_eq!(state.audit_metadata(), &audit_metadata);
|
||||
assert_eq!(state.environment_id(), Some("remote"));
|
||||
assert_eq!(state.execution_id().as_deref(), Some("execution-1"));
|
||||
}
|
||||
@@ -23,6 +23,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use globset::GlobSet;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -47,7 +48,8 @@ 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, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkProxyAuditMetadata {
|
||||
pub conversation_id: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
@@ -187,6 +189,22 @@ pub trait ConfigReloader: Send + Sync {
|
||||
|
||||
pub type ConfigReloaderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
|
||||
|
||||
struct StaticConfigReloader;
|
||||
|
||||
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> {
|
||||
Box::pin(async { anyhow::bail!("static config state cannot be reloaded") })
|
||||
}
|
||||
}
|
||||
|
||||
pub trait BlockedRequestObserver: Send + Sync + 'static {
|
||||
fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_>;
|
||||
}
|
||||
@@ -257,6 +275,33 @@ impl Clone for NetworkProxyState {
|
||||
}
|
||||
|
||||
impl NetworkProxyState {
|
||||
/// Builds runtime state for one executor-local proxy launch.
|
||||
pub fn from_remote_launch_config(
|
||||
launch: crate::RemoteNetworkProxyLaunchConfig,
|
||||
) -> Result<Self> {
|
||||
let crate::RemoteNetworkProxyLaunchConfig {
|
||||
proxy,
|
||||
audit_metadata,
|
||||
environment_id,
|
||||
execution_id,
|
||||
} = launch;
|
||||
anyhow::ensure!(
|
||||
proxy.enabled,
|
||||
"executor-local network proxy launch requires an enabled proxy"
|
||||
);
|
||||
let config = proxy.into_network_proxy_config();
|
||||
let state = build_config_state(config, NetworkProxyConstraints::default())?;
|
||||
Ok(Self {
|
||||
environment_id: environment_id.map(Into::into),
|
||||
execution_id: execution_id.map(Into::into),
|
||||
..Self::with_reloader_and_audit_metadata(
|
||||
state,
|
||||
Arc::new(StaticConfigReloader),
|
||||
audit_metadata,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
|
||||
Self::with_reloader_and_audit_metadata(
|
||||
state,
|
||||
|
||||
@@ -517,6 +517,7 @@ impl ExecutorStdioServerLauncher {
|
||||
sandbox: None,
|
||||
enforce_managed_network: false,
|
||||
managed_network: None,
|
||||
network_proxy: None,
|
||||
})
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
|
||||
Reference in New Issue
Block a user