From de8a76beee5746ba2ab25fbf80a2663d1bdcfe9f Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 29 May 2026 18:06:57 -0700 Subject: [PATCH] Add exec-server sandbox launch intent --- codex-rs/Cargo.lock | 1 + .../core/src/tools/runtimes/unified_exec.rs | 86 +++++++++ codex-rs/core/src/tools/sandboxing.rs | 16 ++ .../core/src/unified_exec/process_manager.rs | 84 +++++++- .../src/unified_exec/process_manager_tests.rs | 49 +++++ .../core/src/unified_exec/process_tests.rs | 2 + codex-rs/exec-server/src/client.rs | 23 +++ codex-rs/exec-server/src/environment.rs | 18 +- codex-rs/exec-server/src/lib.rs | 5 + codex-rs/exec-server/src/local_process.rs | 45 ++++- codex-rs/exec-server/src/process.rs | 2 + codex-rs/exec-server/src/process_sandbox.rs | 181 ++++++++++++++++++ codex-rs/exec-server/src/protocol.rs | 97 ++++++++++ codex-rs/exec-server/src/remote_process.rs | 12 +- codex-rs/exec-server/src/server/handler.rs | 13 +- .../exec-server/src/server/handler/tests.rs | 10 +- .../exec-server/src/server/process_handler.rs | 7 +- codex-rs/exec-server/src/server/processor.rs | 6 +- .../src/server/session_registry.rs | 15 +- codex-rs/exec-server/tests/exec_process.rs | 27 ++- codex-rs/exec-server/tests/http_client.rs | 1 + codex-rs/exec-server/tests/process.rs | 179 ++++++++++++++++- .../rmcp-client/src/stdio_server_launcher.rs | 2 + codex-rs/sandboxing/Cargo.toml | 1 + codex-rs/sandboxing/src/manager.rs | 5 +- 25 files changed, 843 insertions(+), 44 deletions(-) create mode 100644 codex-rs/exec-server/src/process_sandbox.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2eeafe30b9..f901ecb9d9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3621,6 +3621,7 @@ dependencies = [ "libc", "pretty_assertions", "regex-lite", + "serde", "serde_json", "tempfile", "tokio", diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index e49c925728..aefedd2323 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -41,6 +41,7 @@ use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_exec_server::Environment; +use codex_exec_server::ExecServerCapability; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -110,6 +111,24 @@ fn unified_exec_options( } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DirectExecServerLaunch { + Materialized, + SandboxIntent, +} + +fn direct_exec_server_launch( + environment_is_remote: bool, + supports_sandbox_intent: bool, + requires_core_network_realization: bool, +) -> DirectExecServerLaunch { + if environment_is_remote && supports_sandbox_intent && !requires_core_network_realization { + DirectExecServerLaunch::SandboxIntent + } else { + DirectExecServerLaunch::Materialized + } +} + 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 { @@ -370,6 +389,41 @@ impl<'a> ToolRuntime for UnifiedExecRunt let command = build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let supports_sandbox_intent = if environment_is_remote { + req.environment + .supports_exec_server_capability(ExecServerCapability::ProcessStartSandboxIntent) + .await + .map_err(|err| ToolError::Rejected(err.to_string()))? + } else { + false + }; + if direct_exec_server_launch( + environment_is_remote, + supports_sandbox_intent, + managed_network.is_some() || attempt.enforce_managed_network, + ) == DirectExecServerLaunch::SandboxIntent + { + return self + .manager + .open_session_with_exec_server_sandbox_intent( + req.process_id, + command, + attempt.exec_server_sandbox_intent(), + req.exec_server_env_config.as_ref(), + req.tty, + req.environment.as_ref(), + ) + .await + .map_err(|err| match err { + UnifiedExecError::SandboxDenied { output, .. } => { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + })) + } + other => ToolError::Rejected(other.to_string()), + }); + } let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); let mut exec_env = attempt .env_for(command, options, managed_network) @@ -427,6 +481,38 @@ mod tests { } } + #[test] + fn direct_remote_exec_server_uses_sandbox_intent_only_when_network_stays_executor_local() { + assert_eq!( + direct_exec_server_launch( + /*environment_is_remote*/ true, /*supports_sandbox_intent*/ true, + /*requires_core_network_realization*/ false, + ), + DirectExecServerLaunch::SandboxIntent, + ); + assert_eq!( + direct_exec_server_launch( + /*environment_is_remote*/ true, /*supports_sandbox_intent*/ false, + /*requires_core_network_realization*/ false, + ), + DirectExecServerLaunch::Materialized, + ); + assert_eq!( + direct_exec_server_launch( + /*environment_is_remote*/ true, /*supports_sandbox_intent*/ true, + /*requires_core_network_realization*/ true, + ), + DirectExecServerLaunch::Materialized, + ); + assert_eq!( + direct_exec_server_launch( + /*environment_is_remote*/ false, /*supports_sandbox_intent*/ true, + /*requires_core_network_realization*/ false, + ), + DirectExecServerLaunch::Materialized, + ); + } + #[tokio::test] async fn unified_exec_uses_the_trusted_sandbox_cwd() { let cwd_dir = tempdir().expect("create process temp dir"); diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 598d8855af..d85ebf46dc 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -421,6 +421,22 @@ pub(crate) struct SandboxAttempt<'a> { } impl<'a> SandboxAttempt<'a> { + pub fn exec_server_sandbox_intent(&self) -> codex_exec_server::ExecSandboxIntent { + codex_exec_server::ExecSandboxIntent { + sandbox: if self.sandbox == SandboxType::None { + codex_exec_server::ExecSandboxMode::None + } else { + codex_exec_server::ExecSandboxMode::Platform + }, + permissions: self.permissions.clone(), + sandbox_policy_cwd: self.sandbox_cwd.clone(), + use_legacy_landlock: self.use_legacy_landlock, + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + additional_permissions: None, + } + } + pub fn env_for( &self, command: SandboxCommand, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 83c750ebc4..eb0fd11f6d 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -129,19 +129,20 @@ fn env_overlay_for_exec_server( .collect() } -fn exec_server_env_for_request( - request: &ExecRequest, +fn exec_server_env_for_command( + env: &HashMap, + exec_server_env_config: Option<&ExecServerEnvConfig>, ) -> ( Option, HashMap, ) { - if let Some(exec_server_env_config) = &request.exec_server_env_config { + if let Some(exec_server_env_config) = exec_server_env_config { ( Some(exec_server_env_config.policy.clone()), - env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env), + env_overlay_for_exec_server(env, &exec_server_env_config.local_policy_env), ) } else { - (None, request.env.clone()) + (None, env.clone()) } } @@ -150,7 +151,8 @@ fn exec_server_params_for_request( request: &ExecRequest, tty: bool, ) -> codex_exec_server::ExecParams { - let (env_policy, env) = exec_server_env_for_request(request); + let (env_policy, env) = + exec_server_env_for_command(&request.env, request.exec_server_env_config.as_ref()); codex_exec_server::ExecParams { process_id: exec_server_process_id(process_id).into(), argv: request.command.clone(), @@ -160,9 +162,45 @@ fn exec_server_params_for_request( tty, pipe_stdin: false, arg0: request.arg0.clone(), + launch: codex_exec_server::ExecLaunch::Materialized, } } +fn exec_server_params_for_sandbox_intent( + process_id: i32, + command: codex_sandboxing::SandboxCommand, + mut intent: codex_exec_server::ExecSandboxIntent, + exec_server_env_config: Option<&ExecServerEnvConfig>, + tty: bool, +) -> Result { + let codex_sandboxing::SandboxCommand { + program, + args, + cwd, + env, + additional_permissions, + } = command; + let program = program.into_string().map_err(|program| { + UnifiedExecError::create_process(format!( + "exec-server sandbox intent program is not UTF-8: {}", + program.to_string_lossy() + )) + })?; + let (env_policy, env) = exec_server_env_for_command(&env, exec_server_env_config); + intent.additional_permissions = additional_permissions; + Ok(codex_exec_server::ExecParams { + process_id: exec_server_process_id(process_id).into(), + argv: std::iter::once(program).chain(args).collect(), + cwd: cwd.to_path_buf(), + env_policy, + env, + tty, + pipe_stdin: false, + arg0: None, + launch: codex_exec_server::ExecLaunch::SandboxIntent { intent }, + }) +} + /// Borrowed process state prepared for a `write_stdin` or poll operation. struct PreparedProcessHandles { process: Arc, @@ -989,6 +1027,40 @@ impl UnifiedExecProcessManager { UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await } + pub(crate) async fn open_session_with_exec_server_sandbox_intent( + &self, + process_id: i32, + command: codex_sandboxing::SandboxCommand, + intent: codex_exec_server::ExecSandboxIntent, + exec_server_env_config: Option<&ExecServerEnvConfig>, + tty: bool, + environment: &codex_exec_server::Environment, + ) -> Result { + if !environment.is_remote() { + return Err(UnifiedExecError::create_process( + "exec-server sandbox intent requires a remote environment".to_string(), + )); + } + + let started = environment + .get_exec_backend() + .start(exec_server_params_for_sandbox_intent( + process_id, + command, + intent, + exec_server_env_config, + tty, + )?) + .await + .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; + let sandbox = started.sandbox.ok_or_else(|| { + UnifiedExecError::create_process( + "exec-server sandbox intent response omitted materialized sandbox".to_string(), + ) + })?; + UnifiedExecProcess::from_exec_server_started(started, sandbox).await + } + pub(super) async fn open_session_with_sandbox( &self, request: &ExecCommandRequest, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 4d0597a844..7beb4bebc5 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -123,6 +123,55 @@ fn exec_server_params_use_env_policy_overlay_contract() { ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), ]) ); + assert_eq!(params.launch, codex_exec_server::ExecLaunch::Materialized); +} + +#[test] +fn exec_server_sandbox_intent_params_keep_pre_transform_command() { + let cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir() + .expect("current dir") + .try_into() + .expect("absolute path"); + let command = codex_sandboxing::SandboxCommand { + program: "bash".into(), + args: vec!["-lc".to_string(), "true".to_string()], + cwd: cwd.clone(), + env: HashMap::from([("PATH".to_string(), "/sandbox-path".to_string())]), + additional_permissions: None, + }; + let intent = codex_exec_server::ExecSandboxIntent { + sandbox: codex_exec_server::ExecSandboxMode::None, + permissions: codex_protocol::models::PermissionProfile::Disabled, + sandbox_policy_cwd: cwd, + use_legacy_landlock: false, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + additional_permissions: None, + }; + + let params = exec_server_params_for_sandbox_intent( + /*process_id*/ 123, + command, + intent.clone(), + /*exec_server_env_config*/ None, + /*tty*/ true, + ) + .expect("sandbox intent params"); + + assert_eq!( + params, + codex_exec_server::ExecParams { + process_id: "123".into(), + argv: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], + cwd: std::env::current_dir().expect("current dir"), + env_policy: None, + env: HashMap::from([("PATH".to_string(), "/sandbox-path".to_string())]), + tty: true, + pipe_stdin: false, + arg0: None, + launch: codex_exec_server::ExecLaunch::SandboxIntent { intent }, + } + ); } #[test] diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index ee0ea1cba3..8483335582 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -79,6 +79,7 @@ async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess { read_responses: Mutex::new(VecDeque::new()), wake_tx, }), + sandbox: None, }; UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) @@ -145,6 +146,7 @@ async fn remote_process_waits_for_early_exit_event() { }])), wake_tx: wake_tx.clone(), }), + sandbox: None, }; tokio::spawn(async move { diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index e06a736c17..e0ba127361 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -41,6 +41,7 @@ use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; +use crate::protocol::ExecServerCapability; use crate::protocol::FS_COPY_METHOD; use crate::protocol::FS_CREATE_DIRECTORY_METHOD; use crate::protocol::FS_GET_METADATA_METHOD; @@ -175,6 +176,7 @@ struct Inner { http_body_streams_write_lock: Mutex<()>, http_body_stream_next_id: AtomicU64, session_id: std::sync::RwLock>, + capabilities: std::sync::RwLock>, reader_task: tokio::task::JoinHandle<()>, } @@ -341,6 +343,14 @@ impl ExecServerClient { .unwrap_or_else(std::sync::PoisonError::into_inner); *session_id = Some(response.session_id.clone()); } + { + let mut capabilities = self + .inner + .capabilities + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *capabilities = response.capabilities.clone(); + } self.notify_initialized().await?; Ok(response) }) @@ -459,6 +469,14 @@ impl ExecServerClient { .clone() } + pub fn supports(&self, capability: ExecServerCapability) -> bool { + self.inner + .capabilities + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&capability) + } + fn is_disconnected(&self) -> bool { self.inner.disconnected.get().is_some() || self.inner.client.is_disconnected() } @@ -510,6 +528,7 @@ impl ExecServerClient { http_body_streams_write_lock: Mutex::new(()), http_body_stream_next_id: AtomicU64::new(1), session_id: std::sync::RwLock::new(None), + capabilities: std::sync::RwLock::new(Vec::new()), reader_task, } }); @@ -1047,6 +1066,7 @@ mod tests { id: request.id, result: serde_json::to_value(InitializeResponse { session_id: session_id.to_string(), + capabilities: Vec::new(), }) .expect("initialize response should serialize"), }), @@ -1280,6 +1300,7 @@ mod tests { id: request.id, result: serde_json::to_value(InitializeResponse { session_id: "session-1".to_string(), + capabilities: Vec::new(), }) .expect("initialize response should serialize"), }), @@ -1423,6 +1444,7 @@ mod tests { id: request.id, result: serde_json::to_value(InitializeResponse { session_id: "session-1".to_string(), + capabilities: Vec::new(), }) .expect("initialize response should serialize"), }), @@ -1560,6 +1582,7 @@ mod tests { id: request.id, result: serde_json::to_value(InitializeResponse { session_id: "session-1".to_string(), + capabilities: Vec::new(), }) .expect("initialize response should serialize"), }), diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 55dc031273..1edebf83af 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use crate::ExecServerCapability; use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -289,6 +290,7 @@ pub struct Environment { exec_backend: Arc, filesystem: Arc, http_client: Arc, + remote_client: Option, local_runtime_paths: Option, } @@ -301,6 +303,7 @@ impl Environment { exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), + remote_client: None, local_runtime_paths: None, } } @@ -359,6 +362,7 @@ impl Environment { local_runtime_paths.clone(), )), http_client: Arc::new(ReqwestHttpClient), + remote_client: None, local_runtime_paths: Some(local_runtime_paths), } } @@ -394,7 +398,8 @@ impl Environment { remote_transport: Some(remote_transport), exec_backend, filesystem, - http_client: Arc::new(client), + http_client: Arc::new(client.clone()), + remote_client: Some(client), local_runtime_paths, } } @@ -420,6 +425,16 @@ impl Environment { Arc::clone(&self.http_client) } + pub async fn supports_exec_server_capability( + &self, + capability: ExecServerCapability, + ) -> Result { + let Some(client) = &self.remote_client else { + return Ok(false); + }; + Ok(client.get().await?.supports(capability)) + } + pub fn get_filesystem(&self) -> Arc { Arc::clone(&self.filesystem) } @@ -806,6 +821,7 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + launch: crate::ExecLaunch::Materialized, }) .await .expect("start process"); diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index f2d16f8fac..2d8fb118f4 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -12,6 +12,7 @@ mod local_file_system; mod local_process; mod process; mod process_id; +mod process_sandbox; mod protocol; mod relay; mod relay_proto; @@ -58,10 +59,14 @@ pub use process_id::ProcessId; pub use protocol::ExecClosedNotification; pub use protocol::ExecEnvPolicy; pub use protocol::ExecExitedNotification; +pub use protocol::ExecLaunch; pub use protocol::ExecOutputDeltaNotification; pub use protocol::ExecOutputStream; pub use protocol::ExecParams; pub use protocol::ExecResponse; +pub use protocol::ExecSandboxIntent; +pub use protocol::ExecSandboxMode; +pub use protocol::ExecServerCapability; pub use protocol::FsCopyParams; pub use protocol::FsCopyResponse; pub use protocol::FsCreateDirectoryParams; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index bc69ec6105..8392f9096e 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -24,6 +24,7 @@ use crate::ExecServerError; use crate::ProcessId; use crate::StartedExecProcess; use crate::process::ExecProcessEventLog; +use crate::process_sandbox::ProcessSandboxTransformer; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecEnvPolicy; @@ -84,6 +85,7 @@ enum ProcessEntry { struct Inner { notifications: std::sync::RwLock>, processes: Mutex>, + sandbox: Option, } #[derive(Clone)] @@ -113,6 +115,20 @@ impl LocalProcess { inner: Arc::new(Inner { notifications: std::sync::RwLock::new(Some(notifications)), processes: Mutex::new(HashMap::new()), + sandbox: None, + }), + } + } + + pub(crate) fn with_runtime_paths( + notifications: RpcNotificationSender, + runtime_paths: crate::ExecServerRuntimePaths, + ) -> Self { + Self { + inner: Arc::new(Inner { + notifications: std::sync::RwLock::new(Some(notifications)), + processes: Mutex::new(HashMap::new()), + sandbox: Some(ProcessSandboxTransformer::new(runtime_paths)), }), } } @@ -146,6 +162,21 @@ impl LocalProcess { &self, params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { + let materialized = match (&self.inner.sandbox, ¶ms.launch) { + (Some(sandbox), _) => sandbox.materialize(params)?, + (None, crate::protocol::ExecLaunch::Materialized) => { + crate::process_sandbox::MaterializedProcess { + params, + sandbox: None, + } + } + (None, crate::protocol::ExecLaunch::SandboxIntent { .. }) => { + return Err(crate::rpc::invalid_request( + "sandbox intent requires exec-server runtime paths".to_string(), + )); + } + }; + let params = materialized.params; let process_id = params.process_id.clone(); let (program, args) = params .argv @@ -259,7 +290,14 @@ impl LocalProcess { output_notify, )); - Ok((ExecResponse { process_id }, wake_tx, events)) + Ok(( + ExecResponse { + process_id, + sandbox: materialized.sandbox, + }, + wake_tx, + events, + )) } pub(crate) async fn exec(&self, params: ExecParams) -> Result { @@ -406,7 +444,7 @@ impl LocalProcess { } } -fn child_env(params: &ExecParams) -> HashMap { +pub(crate) fn child_env(params: &ExecParams) -> HashMap { let Some(env_policy) = ¶ms.env_policy else { return params.env.clone(); }; @@ -444,6 +482,7 @@ impl ExecBackend for LocalProcess { .await .map_err(map_handler_error)?; Ok(StartedExecProcess { + sandbox: response.sandbox, process: Arc::new(LocalExecProcess { process_id: response.process_id, backend: self.clone(), @@ -706,6 +745,7 @@ fn notification_sender(inner: &Inner) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::protocol::ExecLaunch; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_utils_pty::ProcessDriver; use pretty_assertions::assert_eq; @@ -722,6 +762,7 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, } } diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index cb6c832138..39db3119a7 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -12,9 +12,11 @@ use crate::protocol::ExecParams; use crate::protocol::ProcessOutputChunk; use crate::protocol::ReadResponse; use crate::protocol::WriteResponse; +use codex_sandboxing::SandboxType; pub struct StartedExecProcess { pub process: Arc, + pub sandbox: Option, } /// Pushed process events for consumers that want to follow process output as it diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs new file mode 100644 index 0000000000..930f3fec2b --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -0,0 +1,181 @@ +use codex_app_server_protocol::JSONRPCErrorError; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::ExecServerRuntimePaths; +use crate::protocol::ExecLaunch; +use crate::protocol::ExecParams; +use crate::protocol::ExecSandboxMode; +use crate::rpc::invalid_request; + +#[derive(Clone, Debug)] +pub(crate) struct ProcessSandboxTransformer { + runtime_paths: ExecServerRuntimePaths, +} + +pub(crate) struct MaterializedProcess { + pub(crate) params: ExecParams, + pub(crate) sandbox: Option, +} + +impl ProcessSandboxTransformer { + pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self { + Self { runtime_paths } + } + + pub(crate) fn materialize( + &self, + mut params: ExecParams, + ) -> Result { + let ExecLaunch::SandboxIntent { intent } = params.launch.clone() else { + return Ok(MaterializedProcess { + params, + sandbox: None, + }); + }; + let (program, args) = params + .argv + .split_first() + .ok_or_else(|| invalid_request("sandbox intent argv must not be empty".to_string()))?; + let cwd = AbsolutePathBuf::from_absolute_path(params.cwd.as_path()) + .map_err(|err| invalid_request(format!("sandbox intent cwd is invalid: {err}")))?; + let sandbox = executor_sandbox_type(intent.sandbox, intent.windows_sandbox_level)?; + let command = SandboxCommand { + program: program.clone().into(), + args: args.to_vec(), + cwd, + env: crate::local_process::child_env(¶ms), + additional_permissions: intent.additional_permissions, + }; + let request = SandboxManager::new() + .transform(SandboxTransformRequest { + command, + permissions: &intent.permissions, + sandbox, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: intent.sandbox_policy_cwd.as_path(), + codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: intent.use_legacy_landlock, + windows_sandbox_level: intent.windows_sandbox_level, + windows_sandbox_private_desktop: intent.windows_sandbox_private_desktop, + }) + .map_err(|err| invalid_request(format!("failed to prepare process sandbox: {err}")))?; + params.argv = request.command; + params.cwd = request.cwd.to_path_buf(); + params.env_policy = None; + params.env = request.env; + params.arg0 = request.arg0; + params.launch = ExecLaunch::Materialized; + Ok(MaterializedProcess { + params, + sandbox: Some(sandbox), + }) + } +} + +fn executor_sandbox_type( + sandbox: ExecSandboxMode, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, +) -> Result { + match sandbox { + ExecSandboxMode::None => Ok(SandboxType::None), + ExecSandboxMode::Platform => { + let sandbox = codex_sandboxing::get_platform_sandbox( + windows_sandbox_level + != codex_protocol::config_types::WindowsSandboxLevel::Disabled, + ) + .ok_or_else(|| { + invalid_request( + "process sandbox intent requires a platform sandbox on the executor" + .to_string(), + ) + })?; + if sandbox == SandboxType::WindowsRestrictedToken { + return Err(invalid_request( + "process sandbox intent does not support WindowsRestrictedToken yet" + .to_string(), + )); + } + Ok(sandbox) + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::models::PermissionProfile; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + + use super::ProcessSandboxTransformer; + use crate::ExecServerRuntimePaths; + use crate::ProcessId; + use crate::protocol::ExecLaunch; + use crate::protocol::ExecParams; + use crate::protocol::ExecSandboxIntent; + use crate::protocol::ExecSandboxMode; + + #[test] + fn platform_sandbox_intent_uses_executor_platform() { + let sandbox = + super::executor_sandbox_type(ExecSandboxMode::Platform, WindowsSandboxLevel::Disabled); + + match codex_sandboxing::get_platform_sandbox(/*windows_sandbox_enabled*/ false) { + Some(expected) => assert_eq!(sandbox.expect("platform sandbox"), expected), + None => assert!(sandbox.is_err()), + } + } + + #[test] + fn materializes_sandbox_intent_with_executor_runtime_paths() { + let cwd = AbsolutePathBuf::from_absolute_path( + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("absolute cwd"); + let params = ExecParams { + process_id: ProcessId::from("sandbox-intent"), + argv: vec!["true".to_string()], + cwd: cwd.to_path_buf(), + env_policy: None, + env: HashMap::from([("PATH".to_string(), "/usr/bin".to_string())]), + tty: false, + pipe_stdin: false, + arg0: None, + launch: ExecLaunch::SandboxIntent { + intent: ExecSandboxIntent { + sandbox: ExecSandboxMode::None, + permissions: PermissionProfile::Disabled, + sandbox_policy_cwd: cwd, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + additional_permissions: None, + }, + }, + }; + let transformer = ProcessSandboxTransformer::new( + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + ); + + let materialized = transformer.materialize(params).expect("materialize"); + + assert_eq!(materialized.params.launch, ExecLaunch::Materialized); + assert_eq!(materialized.params.argv, vec!["true".to_string()]); + assert_eq!(materialized.params.env_policy, None); + assert_eq!( + materialized.sandbox, + Some(codex_sandboxing::SandboxType::None) + ); + } +} diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index e801a7f437..1ab88c8d93 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -4,6 +4,10 @@ use std::path::PathBuf; use crate::FileSystemSandboxContext; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -59,6 +63,50 @@ pub struct InitializeParams { #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub session_id: String, + #[serde(default)] + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExecServerCapability { + ProcessStartSandboxIntent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExecSandboxMode { + None, + Platform, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecSandboxIntent { + pub sandbox: ExecSandboxMode, + pub permissions: PermissionProfile, + pub sandbox_policy_cwd: AbsolutePathBuf, + pub use_legacy_landlock: bool, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_permissions: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ExecLaunch { + #[default] + Materialized, + SandboxIntent { + intent: ExecSandboxIntent, + }, +} + +impl ExecLaunch { + fn is_materialized(&self) -> bool { + matches!(self, Self::Materialized) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -77,6 +125,8 @@ pub struct ExecParams { #[serde(default)] pub pipe_stdin: bool, pub arg0: Option, + #[serde(default, skip_serializing_if = "ExecLaunch::is_materialized")] + pub launch: ExecLaunch, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -93,6 +143,8 @@ pub struct ExecEnvPolicy { #[serde(rename_all = "camelCase")] pub struct ExecResponse { pub process_id: ProcessId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -398,9 +450,54 @@ mod base64_bytes { #[cfg(test)] mod tests { + use std::collections::HashMap; + + use super::ExecLaunch; + use super::ExecParams; + use super::ExecResponse; use super::HttpRequestParams; + use super::InitializeResponse; + use crate::ProcessId; use pretty_assertions::assert_eq; + #[test] + fn initialize_response_defaults_missing_capabilities() { + let response: InitializeResponse = + serde_json::from_value(serde_json::json!({ "sessionId": "session-1" })) + .expect("initialize response should deserialize"); + + assert_eq!(response.capabilities, Vec::new()); + } + + #[test] + fn materialized_exec_params_omit_launch() { + let params = ExecParams { + process_id: ProcessId::from("proc-1"), + argv: vec!["true".to_string()], + cwd: std::env::current_dir().expect("cwd"), + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + launch: ExecLaunch::Materialized, + }; + + let value = serde_json::to_value(params).expect("exec params should serialize"); + + assert_eq!(value.get("launch"), None); + } + + #[test] + fn exec_response_defaults_missing_sandbox() { + let response: ExecResponse = serde_json::from_value(serde_json::json!({ + "processId": "proc-1", + })) + .expect("legacy exec response should deserialize"); + + assert_eq!(response.sandbox, None); + } + #[test] fn http_request_timeout_treats_omitted_and_null_as_no_timeout() { let omitted: HttpRequestParams = serde_json::from_value(serde_json::json!({ diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index d8d06735cd..f9acdcb178 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -37,12 +37,16 @@ impl ExecBackend for RemoteProcess { let process_id = params.process_id.clone(); let client = self.client.get().await?; let session = client.register_session(&process_id).await?; - if let Err(err) = client.exec(params).await { - session.unregister().await; - return Err(err); - } + let response = match client.exec(params).await { + Ok(response) => response, + Err(err) => { + session.unregister().await; + return Err(err); + } + }; Ok(StartedExecProcess { + sandbox: response.sandbox, process: Arc::new(RemoteExecProcess { session }), }) } diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index d0645724c4..98faf5e4b5 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -123,7 +123,10 @@ impl ExecServerHandler { .session .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(session); - Ok(InitializeResponse { session_id }) + Ok(InitializeResponse { + session_id, + capabilities: process_start_capabilities(), + }) } pub(crate) fn initialized(&self) -> Result<(), String> { @@ -343,5 +346,13 @@ impl ExecServerHandler { } } +fn process_start_capabilities() -> Vec { + if cfg!(windows) { + Vec::new() + } else { + vec![crate::protocol::ExecServerCapability::ProcessStartSandboxIntent] + } +} + #[cfg(test)] mod tests; diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 6b632fe890..827d4dac33 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -9,6 +9,7 @@ use uuid::Uuid; use super::ExecServerHandler; use crate::ExecServerRuntimePaths; use crate::ProcessId; +use crate::protocol::ExecLaunch; use crate::protocol::ExecParams; use crate::protocol::InitializeParams; use crate::protocol::ReadParams; @@ -32,6 +33,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, } } @@ -77,7 +79,7 @@ fn test_runtime_paths() -> ExecServerRuntimePaths { async fn initialized_handler() -> Arc { let (outgoing_tx, _outgoing_rx) = mpsc::channel(16); - let registry = SessionRegistry::new(); + let registry = SessionRegistry::new(test_runtime_paths()); let handler = Arc::new(ExecServerHandler::new( registry, RpcNotificationSender::new(outgoing_tx), @@ -155,7 +157,7 @@ async fn terminate_reports_false_after_process_exit() { #[tokio::test] async fn long_poll_read_fails_after_session_resume() { let (first_tx, _first_rx) = mpsc::channel(16); - let registry = SessionRegistry::new(); + let registry = SessionRegistry::new(test_runtime_paths()); let first_handler = Arc::new(ExecServerHandler::new( Arc::clone(®istry), RpcNotificationSender::new(first_tx), @@ -228,7 +230,7 @@ async fn long_poll_read_fails_after_session_resume() { #[tokio::test] async fn active_session_resume_is_rejected() { let (first_tx, _first_rx) = mpsc::channel(16); - let registry = SessionRegistry::new(); + let registry = SessionRegistry::new(test_runtime_paths()); let first_handler = Arc::new(ExecServerHandler::new( Arc::clone(®istry), RpcNotificationSender::new(first_tx), @@ -272,7 +274,7 @@ async fn active_session_resume_is_rejected() { async fn output_and_exit_are_retained_after_notification_receiver_closes() { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); let handler = Arc::new(ExecServerHandler::new( - SessionRegistry::new(), + SessionRegistry::new(test_runtime_paths()), RpcNotificationSender::new(outgoing_tx), test_runtime_paths(), )); diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 38fbace1cd..8974ef85fb 100644 --- a/codex-rs/exec-server/src/server/process_handler.rs +++ b/codex-rs/exec-server/src/server/process_handler.rs @@ -17,9 +17,12 @@ pub(crate) struct ProcessHandler { } impl ProcessHandler { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: crate::ExecServerRuntimePaths, + ) -> Self { Self { - process: LocalProcess::new(notifications), + process: LocalProcess::with_runtime_paths(notifications, runtime_paths), } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 6fc0723f0c..b958bb4e89 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -26,7 +26,7 @@ pub(crate) struct ConnectionProcessor { impl ConnectionProcessor { pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self { Self { - session_registry: SessionRegistry::new(), + session_registry: SessionRegistry::new(runtime_paths.clone()), runtime_paths, } } @@ -213,6 +213,7 @@ mod tests { use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; + use crate::protocol::ExecLaunch; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::INITIALIZE_METHOD; @@ -226,7 +227,7 @@ mod tests { #[tokio::test] async fn transport_disconnect_detaches_session_during_in_flight_read() { - let registry = SessionRegistry::new(); + let registry = SessionRegistry::new(test_runtime_paths()); let (mut first_writer, mut first_lines, first_task) = spawn_test_connection(Arc::clone(®istry), "first"); @@ -402,6 +403,7 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, } } diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 82c779c6bb..3b726f412a 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use tokio::sync::Mutex; use uuid::Uuid; +use crate::ExecServerRuntimePaths; use crate::rpc::RpcNotificationSender; use crate::rpc::invalid_request; use crate::server::process_handler::ProcessHandler; @@ -18,6 +19,7 @@ const DETACHED_SESSION_TTL: Duration = Duration::from_secs(10); pub(crate) struct SessionRegistry { sessions: Mutex>>, + runtime_paths: ExecServerRuntimePaths, } struct SessionEntry { @@ -49,9 +51,10 @@ pub(crate) struct SessionHandle { } impl SessionRegistry { - pub(crate) fn new() -> Arc { + pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Arc { Arc::new(Self { sessions: Mutex::new(HashMap::new()), + runtime_paths, }) } @@ -94,7 +97,7 @@ impl SessionRegistry { let session_id = Uuid::new_v4().to_string(); let entry = Arc::new(SessionEntry::new( session_id.clone(), - ProcessHandler::new(notifications), + ProcessHandler::new(notifications, self.runtime_paths.clone()), connection_id, )); sessions.insert(session_id, Arc::clone(&entry)); @@ -136,14 +139,6 @@ impl SessionRegistry { } } -impl Default for SessionRegistry { - fn default() -> Self { - Self { - sessions: Mutex::new(HashMap::new()), - } - } -} - impl SessionEntry { fn new(session_id: String, process: ProcessHandler, connection_id: ConnectionId) -> Self { Self { diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index e1f330fc4e..654d78ad33 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -8,6 +8,7 @@ use anyhow::Context; use anyhow::Result; use codex_exec_server::Environment; use codex_exec_server::ExecBackend; +use codex_exec_server::ExecLaunch; use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; @@ -79,6 +80,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -220,11 +222,12 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + 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_eq!(output, "session output\n"); @@ -251,11 +254,12 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -298,11 +302,12 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -346,11 +351,12 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let exit_response = timeout( Duration::from_secs(2), @@ -419,13 +425,14 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tty: true, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -456,6 +463,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tty: false, pipe_stdin: true, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -463,7 +471,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -489,13 +497,14 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -524,12 +533,13 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + 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_eq!(output, "queued output\n"); @@ -558,6 +568,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + launch: ExecLaunch::Materialized, }) .await?; diff --git a/codex-rs/exec-server/tests/http_client.rs b/codex-rs/exec-server/tests/http_client.rs index 6a087dd115..bcd30b79c1 100644 --- a/codex-rs/exec-server/tests/http_client.rs +++ b/codex-rs/exec-server/tests/http_client.rs @@ -1013,6 +1013,7 @@ impl JsonRpcPeer { request.id, InitializeResponse { session_id: "session-1".to_string(), + capabilities: Vec::new(), }, ) .await?; diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 334e449224..e5cc9a960d 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -4,7 +4,12 @@ mod common; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_exec_server::ExecLaunch; +use codex_exec_server::ExecParams; use codex_exec_server::ExecResponse; +use codex_exec_server::ExecSandboxIntent; +use codex_exec_server::ExecSandboxMode; +use codex_exec_server::ExecServerCapability; use codex_exec_server::InitializeParams; use codex_exec_server::InitializeResponse; use codex_exec_server::ProcessId; @@ -12,6 +17,9 @@ use codex_exec_server::ReadResponse; use codex_exec_server::TerminateResponse; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; use common::exec_server::exec_server; use pretty_assertions::assert_eq; @@ -27,7 +35,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { })?, ) .await?; - let _ = server + let initialize_response = server .wait_for_event(|event| { matches!( event, @@ -35,6 +43,16 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { ) }) .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = initialize_response else { + panic!("expected initialize response"); + }; + let initialize_response: InitializeResponse = serde_json::from_value(result)?; + let expected_capabilities = if cfg!(windows) { + Vec::new() + } else { + vec![ExecServerCapability::ProcessStartSandboxIntent] + }; + assert_eq!(initialize_response.capabilities, expected_capabilities); server .send_notification("initialized", serde_json::json!({})) @@ -70,7 +88,161 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), + sandbox: None, + } + ); + + server.shutdown().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_materializes_process_sandbox_intent() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let initialize_id = server + .send_request( + "initialize", + serde_json::to_value(InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + })?, + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id + ) + }) + .await?; + server + .send_notification("initialized", serde_json::json!({})) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(std::env::current_dir()?.as_path())?; + let process_start_id = server + .send_request( + "process/start", + serde_json::to_value(ExecParams { + process_id: ProcessId::from("proc-sandbox-intent"), + argv: vec!["true".to_string()], + cwd: cwd.to_path_buf(), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + launch: ExecLaunch::SandboxIntent { + intent: ExecSandboxIntent { + sandbox: ExecSandboxMode::None, + permissions: PermissionProfile::Disabled, + sandbox_policy_cwd: cwd.clone(), + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + additional_permissions: None, + }, + }, + })?, + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/start response"); + }; + let process_start_response: ExecResponse = serde_json::from_value(result)?; + + assert_eq!( + process_start_response, + ExecResponse { + process_id: ProcessId::from("proc-sandbox-intent"), + sandbox: Some(codex_sandboxing::SandboxType::None), + } + ); + + server.shutdown().await?; + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_materializes_platform_process_sandbox_intent() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let initialize_id = server + .send_request( + "initialize", + serde_json::to_value(InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + })?, + ) + .await?; + let _ = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id + ) + }) + .await?; + server + .send_notification("initialized", serde_json::json!({})) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(std::env::current_dir()?.as_path())?; + let process_start_id = server + .send_request( + "process/start", + serde_json::to_value(ExecParams { + process_id: ProcessId::from("proc-platform-sandbox-intent"), + argv: vec!["true".to_string()], + cwd: cwd.to_path_buf(), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + launch: ExecLaunch::SandboxIntent { + intent: ExecSandboxIntent { + sandbox: ExecSandboxMode::Platform, + permissions: PermissionProfile::Disabled, + sandbox_policy_cwd: cwd, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + additional_permissions: None, + }, + }, + })?, + ) + .await?; + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &process_start_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + panic!("expected process/start response"); + }; + let process_start_response: ExecResponse = serde_json::from_value(result)?; + + assert_eq!( + process_start_response, + ExecResponse { + process_id: ProcessId::from("proc-platform-sandbox-intent"), + sandbox: codex_sandboxing::get_platform_sandbox(/*windows_sandbox_enabled*/ false), } ); @@ -135,7 +307,8 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin") + process_id: ProcessId::from("proc-default-stdin"), + sandbox: None, } ); diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 8b32506694..617fe3b336 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -32,6 +32,7 @@ use anyhow::anyhow; use codex_config::types::McpServerEnvVar; use codex_exec_server::ExecBackend; use codex_exec_server::ExecEnvPolicy; +use codex_exec_server::ExecLaunch; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; @@ -501,6 +502,7 @@ impl ExecutorStdioServerLauncher { tty: false, pipe_stdin: true, arg0: None, + launch: ExecLaunch::Materialized, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 858219e9fc..36e86a4932 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -18,6 +18,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } dunce = { workspace = true } libc = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } regex-lite = { workspace = true } tracing = { workspace = true, features = ["log"] } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 82c49f7908..80757fab1b 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -15,11 +15,14 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; use std::ffi::OsString; use std::path::Path; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum SandboxType { None, MacosSeatbelt,