diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5255bacf7dcf..02b2a9aed9ac 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3870,6 +3870,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-home-dir", "codex-utils-path-uri", + "codex-utils-pty", "codex-windows-sandbox", "dunce", "libc", diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index d7f0b90d5c88..197489434ced 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -275,13 +275,22 @@ impl CommandExecManager { &env, &arg0, size.unwrap_or_default(), + &[], ) .await } else if stream_stdin { - codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await - } else { - codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0, &[]) .await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin( + program, + args, + cwd.as_path(), + &env, + &arg0, + &[], + ) + .await }; let spawned = match spawned { Ok(spawned) => spawned, diff --git a/codex-rs/app-server/src/request_processors/process_exec_processor.rs b/codex-rs/app-server/src/request_processors/process_exec_processor.rs index 0b84c7f7b994..8b9b50843005 100644 --- a/codex-rs/app-server/src/request_processors/process_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/process_exec_processor.rs @@ -312,13 +312,22 @@ impl ProcessExecManager { &env, &arg0, size.unwrap_or_default(), + &[], ) .await } else if stream_stdin { - codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await - } else { - codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0, &[]) .await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin( + program, + args, + cwd.as_path(), + &env, + &arg0, + &[], + ) + .await }; let spawned = match spawned { Ok(spawned) => spawned, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index d4418ea247e0..74744be52f18 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1004,92 +1004,6 @@ impl UnifiedExecProcessManager { ) -> Result { let inherited_fds = spawn_lifecycle.inherited_fds(); - #[cfg(target_os = "windows")] - if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { - // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. - let native_cwd = - request - .cwd - .to_abs_path() - .map_err(|_| UnifiedExecError::ForeignPath { - path: request.cwd.clone(), - })?; - let codex_home = crate::config::find_codex_home().map_err(|err| { - UnifiedExecError::create_process(format!( - "windows sandbox: failed to resolve codex_home: {err}" - )) - })?; - let additional_deny_write_paths = request - .windows_sandbox_filesystem_overrides - .as_ref() - .map(|overrides| overrides.additional_deny_write_paths.clone()) - .unwrap_or_default(); - let additional_deny_read_paths = request - .windows_sandbox_filesystem_overrides - .as_ref() - .map(|overrides| overrides.additional_deny_read_paths.clone()) - .unwrap_or_default(); - let elevated_read_roots_override = request - .windows_sandbox_filesystem_overrides - .as_ref() - .and_then(|overrides| overrides.read_roots_override.clone()); - let elevated_read_roots_include_platform_defaults = request - .windows_sandbox_filesystem_overrides - .as_ref() - .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); - let elevated_write_roots_override = request - .windows_sandbox_filesystem_overrides - .as_ref() - .and_then(|overrides| overrides.write_roots_override.clone()); - let spawned = match request.windows_sandbox_level { - codex_protocol::config_types::WindowsSandboxLevel::Elevated => { - codex_windows_sandbox::spawn_windows_sandbox_session_elevated_for_permission_profile( - &request.permission_profile, - request.windows_sandbox_workspace_roots.as_slice(), - codex_home.as_ref(), - request.command.clone(), - native_cwd.as_path(), - request.env.clone(), - request.network.is_some(), - None, - elevated_read_roots_override.as_deref(), - elevated_read_roots_include_platform_defaults, - elevated_write_roots_override.as_deref(), - &additional_deny_read_paths, - &additional_deny_write_paths, - tty, - tty, - request.windows_sandbox_private_desktop, - ) - .await - } - codex_protocol::config_types::WindowsSandboxLevel::RestrictedToken - | codex_protocol::config_types::WindowsSandboxLevel::Disabled => { - codex_windows_sandbox::spawn_windows_sandbox_session_legacy( - &request.permission_profile, - request.windows_sandbox_workspace_roots.as_slice(), - codex_home.as_ref(), - request.command.clone(), - native_cwd.as_path(), - request.env.clone(), - None, - &additional_deny_read_paths, - &additional_deny_write_paths, - tty, - tty, - request.windows_sandbox_private_desktop, - ) - .await - } - }; - spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_spawned( - spawned.map_err(|err| UnifiedExecError::create_process(err.to_string()))?, - request.sandbox, - spawn_lifecycle, - ) - .await; - } if environment.is_remote() { if !inherited_fds.is_empty() { return Err(UnifiedExecError::create_process( @@ -1114,35 +1028,39 @@ impl UnifiedExecProcessManager { path: request.cwd.clone(), })?; - let (program, args) = request - .command - .split_first() - .ok_or(UnifiedExecError::MissingCommandLine)?; - let spawn_result = if tty { - codex_utils_pty::pty::spawn_process_with_inherited_fds( - program, - args, - native_cwd.as_path(), - &request.env, - &request.arg0, - codex_utils_pty::TerminalSize::default(), - &inherited_fds, - ) - .await + if request.command.is_empty() { + return Err(UnifiedExecError::MissingCommandLine); + } + let windows_sandbox = if request.sandbox + == codex_sandboxing::SandboxType::WindowsRestrictedToken + { + Some(codex_sandboxing::WindowsSandboxSpawnRequest { + permission_profile: &request.permission_profile, + workspace_roots: &request.windows_sandbox_workspace_roots, + windows_sandbox_level: request.windows_sandbox_level, + proxy_enforced: request.network.is_some(), + proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, + filesystem_overrides: request.windows_sandbox_filesystem_overrides.as_ref(), + use_private_desktop: request.windows_sandbox_private_desktop, + }) } else { - codex_utils_pty::pipe::spawn_process_no_stdin_with_inherited_fds( - program, - args, - native_cwd.as_path(), - &request.env, - &request.arg0, - &inherited_fds, - ) - .await + None }; + let spawn_result = codex_sandboxing::spawn_process(codex_sandboxing::SpawnRequest { + command: &request.command, + cwd: native_cwd.as_path(), + env: &request.env, + arg0: &request.arg0, + sandbox: request.sandbox, + windows_sandbox, + tty, + stdin_open: tty, + inherited_fds: &inherited_fds, + }) + .await; + spawn_lifecycle.after_spawn(); let spawned = spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?; - spawn_lifecycle.after_spawn(); UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await } diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index bd031127b788..e066897b4c4a 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -5,6 +5,8 @@ use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_path_uri::PathUri; +#[cfg(windows)] +use core_test_support::PathExt; use core_test_support::TestTargetOs; use core_test_support::responses::ResponseMock; use core_test_support::responses::ev_apply_patch_custom_tool_call; @@ -15,7 +17,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_target_windows; +use core_test_support::skip_if_wine_exec; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; @@ -36,12 +38,19 @@ fn workspace_roots_profile() -> PermissionProfile { async fn workspace_roots_test(server: &MockServer) -> Result { let mut builder = test_codex().with_config(|config| { + #[cfg(windows)] + { + config.cwd = dunce::canonicalize(config.cwd.as_path()) + .expect("test workspace should be canonicalizable") + .abs(); + } config.use_experimental_unified_exec_tool = true; config .features .enable(Feature::UnifiedExec) .expect("test config should allow feature update"); config.workspace_roots = vec![config.cwd.clone()]; + config.set_windows_sandbox_enabled(/*value*/ true); }); builder.build_with_auto_env(server).await } @@ -60,10 +69,7 @@ fn command_arguments(path: &str, contents: &str) -> Result { TestTargetOs::Linux | TestTargetOs::MacOs => { ("bash", format!("printf %s '{contents}' > '{path}'")) } - TestTargetOs::Windows => ( - "powershell", - format!("Set-Content -NoNewline -Path '{path}' -Value '{contents}'"), - ), + TestTargetOs::Windows => ("cmd", format!("echo {contents}>{path}")), }; Ok(serde_json::to_string(&json!({ "cmd": command, @@ -134,9 +140,9 @@ async fn workspace_roots_allow_file_and_command_writes() -> Result<()> { const PATCH_CONTENTS: &str = "workspace root patch access"; const COMMAND_CONTENTS: &str = "workspace root command access"; - skip_if_target_windows!( + skip_if_wine_exec!( Ok(()), - "sandboxed process launch is not supported by the exec-server Windows backend" + "Wine does not emulate Windows restricted-token and ACL sandbox semantics" ); let server = start_mock_server().await; @@ -173,7 +179,10 @@ async fn workspace_roots_allow_file_and_command_writes() -> Result<()> { read_file(&test, &patch_path).await?, format!("{PATCH_CONTENTS}\n") ); - assert_eq!(read_file(&test, &command_path).await?, COMMAND_CONTENTS); + assert_eq!( + read_file(&test, &command_path).await?.trim_end(), + COMMAND_CONTENTS + ); remove_files(&test, &[&patch_path, &command_path]).await } @@ -183,19 +192,24 @@ async fn workspace_roots_deny_file_and_command_writes_outside_roots() -> Result< const PATCH_CONTENTS: &str = "outside workspace root patch"; const COMMAND_CONTENTS: &str = "outside workspace root command"; - skip_if_target_windows!( + skip_if_wine_exec!( Ok(()), - "sandboxed process launch is not supported by the exec-server Windows backend" + "Wine does not emulate Windows restricted-token and ACL sandbox semantics" ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; let patch_path = outside_workspace_path(&test, "outside-patch.txt")?; let command_path = outside_workspace_path(&test, "outside-command.txt")?; - let patch_path_display = patch_path.inferred_native_path_string(); + let patch_relative_path = format!( + "../{}", + patch_path + .basename() + .context("outside patch path should have a file name")? + ); let command_path_display = command_path.inferred_native_path_string(); let patch = format!( - "*** Begin Patch\n*** Add File: {patch_path_display}\n+{PATCH_CONTENTS}\n*** End Patch\n" + "*** Begin Patch\n*** Add File: {patch_relative_path}\n+{PATCH_CONTENTS}\n*** End Patch\n" ); let response_mock = @@ -222,8 +236,9 @@ async fn workspace_roots_deny_file_and_command_writes_outside_roots() -> Result< .context("denied command result should be present")?; let command_output = command_output.context("denied command output should be present")?; assert!( - command_output.contains(&command_path_display), - "denied command output should identify {command_path_display}, got {command_output:?}" + command_output.contains("Access is denied") + || command_output.contains(&command_path_display), + "outside command should be denied, got {command_output:?}" ); assert!( test.fs() diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index c32ad3522cb6..467a4b0676c8 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -18,7 +18,6 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; -use codex_utils_pty::TerminalSize; use tokio::sync::Mutex; use tokio::sync::Notify; use tokio::sync::mpsc; @@ -241,10 +240,9 @@ impl LocalProcess { let process_id = params.process_id.clone(); let prepared = prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref()).await?; - let (program, args) = prepared - .command - .split_first() - .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + if prepared.command.is_empty() { + return Err(invalid_params("argv must not be empty".to_string())); + } let start = Arc::new(ProcessStart); { @@ -260,35 +258,18 @@ impl LocalProcess { ); } - let spawned_result = if params.tty { - codex_utils_pty::spawn_pty_process( - program, - args, - prepared.cwd.as_path(), - &prepared.env, - &prepared.arg0, - TerminalSize::default(), - ) - .await - } else if params.pipe_stdin { - codex_utils_pty::spawn_pipe_process( - program, - args, - prepared.cwd.as_path(), - &prepared.env, - &prepared.arg0, - ) - .await - } else { - codex_utils_pty::spawn_pipe_process_no_stdin( - program, - args, - prepared.cwd.as_path(), - &prepared.env, - &prepared.arg0, - ) - .await - }; + let spawned_result = codex_sandboxing::spawn_process(codex_sandboxing::SpawnRequest { + command: &prepared.command, + cwd: prepared.cwd.as_path(), + env: &prepared.env, + arg0: &prepared.arg0, + sandbox: prepared.sandbox, + windows_sandbox: prepared.windows_sandbox_spawn_request(), + tty: params.tty, + stdin_open: params.tty || params.pipe_stdin, + inherited_fds: &[], + }) + .await; let spawned = match spawned_result { Ok(spawned) => spawned, Err(err) => { diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index aba1887b5738..b1efa3340254 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -16,6 +16,12 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::WindowsSandboxFilesystemOverrides; +use codex_sandboxing::WindowsSandboxProxySettingsMode; +use codex_sandboxing::WindowsSandboxSpawnRequest; +use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; +use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides; +use codex_sandboxing::windows_sandbox_uses_elevated_backend; use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -32,6 +38,32 @@ pub(crate) struct PreparedExecRequest { pub(crate) arg0: Option, pub(crate) sandbox: SandboxType, pub(crate) network_proxy_handle: Option, + windows_sandbox: Option, +} + +struct PreparedWindowsSandboxRequest { + permission_profile: PermissionProfile, + workspace_roots: Vec, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, + proxy_enforced: bool, + filesystem_overrides: Option, + use_private_desktop: bool, +} + +impl PreparedExecRequest { + pub(crate) fn windows_sandbox_spawn_request(&self) -> Option> { + self.windows_sandbox + .as_ref() + .map(|request| WindowsSandboxSpawnRequest { + permission_profile: &request.permission_profile, + workspace_roots: &request.workspace_roots, + windows_sandbox_level: request.windows_sandbox_level, + proxy_enforced: request.proxy_enforced, + proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile, + filesystem_overrides: request.filesystem_overrides.as_ref(), + use_private_desktop: request.use_private_desktop, + }) + } } pub(crate) async fn prepare_exec_request( @@ -53,6 +85,7 @@ pub(crate) async fn prepare_exec_request( arg0: params.arg0.clone(), sandbox: SandboxType::None, network_proxy_handle, + windows_sandbox: None, }); }; let runtime_paths = runtime_paths @@ -112,54 +145,83 @@ pub(crate) async fn prepare_exec_request( sandbox_context.windows_sandbox_level, params.enforce_managed_network, ); - match sandbox { - SandboxType::None => { - return Err(invalid_params( - "sandbox intent cannot be enforced on this executor".to_string(), - )); - } - SandboxType::WindowsRestrictedToken => { - // TODO(jif): Launch generic remote commands through the Windows sandbox session API - // while preserving argv and TTY behavior and passing the child environment out of band. - return Err(invalid_params( - "sandboxed remote process launch is not supported on Windows".to_string(), - )); - } - SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp => {} + if sandbox == SandboxType::None { + return Err(invalid_params( + "sandbox intent cannot be enforced on this executor".to_string(), + )); } let (program, args) = params .argv .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; - let request = sandbox_manager - .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { - workspace_roots, - windows_sandbox_proxy_settings_mode: - codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, - transform: SandboxTransformRequest { - // TODO(jif): Preserve params.arg0 for the inner command across the sandbox - // wrapper, or reject sandboxed requests with a custom arg0. - command: SandboxCommand { - program: program.into(), - args: args.to_vec(), - cwd: params.cwd.clone(), - env, - managed_network, - additional_permissions: None, - }, - permissions: &permissions, - sandbox, - enforce_managed_network: params.enforce_managed_network, - environment_id: None, - network: None, - sandbox_policy_cwd, - codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), - use_legacy_landlock: sandbox_context.use_legacy_landlock, - windows_sandbox_level: sandbox_context.windows_sandbox_level, - windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + let transform_request = SandboxDirectSpawnTransformRequest { + workspace_roots, + windows_sandbox_proxy_settings_mode: + codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, + transform: SandboxTransformRequest { + // TODO(jif): Preserve params.arg0 for the inner command across the sandbox + // wrapper, or reject sandboxed requests with a custom arg0. + command: SandboxCommand { + program: program.into(), + args: args.to_vec(), + cwd: params.cwd.clone(), + env, + managed_network, + additional_permissions: None, }, - }) + permissions: &permissions, + sandbox, + enforce_managed_network: params.enforce_managed_network, + environment_id: None, + network: None, + sandbox_policy_cwd, + codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + }, + }; + let mut request = if sandbox == SandboxType::WindowsRestrictedToken { + // The shared launcher invokes the native Windows session spawner directly. + sandbox_manager.transform(transform_request.transform) + } else { + sandbox_manager.transform_for_direct_spawn(transform_request) + } + .map_err(|err| invalid_params(format!("failed to prepare process sandbox: {err}")))?; + let windows_sandbox = if sandbox == SandboxType::WindowsRestrictedToken { + request.arg0 = params.arg0.clone(); + let proxy_enforced = params.enforce_managed_network; + let use_elevated = windows_sandbox_uses_elevated_backend( + sandbox_context.windows_sandbox_level, + proxy_enforced, + ); + let filesystem_overrides = if use_elevated { + resolve_windows_elevated_filesystem_overrides( + sandbox, + &permissions, + &native_sandbox_policy_cwd, + use_elevated, + ) + } else { + resolve_windows_restricted_token_filesystem_overrides( + sandbox, + &permissions, + &native_sandbox_policy_cwd, + sandbox_context.windows_sandbox_level, + ) + } .map_err(|err| invalid_params(format!("failed to prepare process sandbox: {err}")))?; + Some(PreparedWindowsSandboxRequest { + permission_profile: permissions, + workspace_roots: native_workspace_roots, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + proxy_enforced, + filesystem_overrides, + use_private_desktop: sandbox_context.windows_sandbox_private_desktop, + }) + } else { + None + }; Ok(PreparedExecRequest { command: request.command, cwd: native_path(&request.cwd, "cwd")?, @@ -167,6 +229,7 @@ pub(crate) async fn prepare_exec_request( arg0: request.arg0, sandbox: request.sandbox, network_proxy_handle, + windows_sandbox, }) } diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 29d57ae8ef3c..c8aafb865fe7 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -7,7 +7,9 @@ use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::RemoteNetworkProxyConfig; use codex_network_proxy::RemoteNetworkProxyLaunchConfig; -#[cfg(unix)] +#[cfg(windows)] +use codex_protocol::config_types::WindowsSandboxLevel; +#[cfg(any(unix, windows))] use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -18,9 +20,9 @@ use tokio::time::timeout; use super::prepare_exec_request; use crate::ExecParams; -#[cfg(unix)] +#[cfg(any(unix, windows))] use crate::ExecServerRuntimePaths; -#[cfg(unix)] +#[cfg(any(unix, windows))] use crate::FileSystemSandboxContext; use crate::ProcessId; @@ -272,3 +274,50 @@ async fn disabled_remote_proxy_config_is_rejected_before_exporting_ports() { .contains("executor-local network proxy launch requires an enabled proxy") ); } + +#[cfg(windows)] +#[tokio::test] +async fn managed_network_selects_elevated_windows_spawn() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = ExecServerRuntimePaths::new(self_exe, None).expect("runtime paths"); + let permissions = PermissionProfile::read_only(); + let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + permissions.clone(), + cwd_uri.clone(), + ); + sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; + let params = ExecParams { + process_id: ProcessId::from("process-managed-network"), + argv: vec!["cmd.exe".to_string(), "/c".to_string(), "exit".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: true, + managed_network: None, + network_proxy: None, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .await + .expect("prepare sandboxed request"); + let spawn = prepared + .windows_sandbox_spawn_request() + .expect("Windows sandbox spawn request"); + + assert_eq!( + spawn.windows_sandbox_level, + WindowsSandboxLevel::RestrictedToken + ); + assert!(spawn.proxy_enforced); + assert_eq!(spawn.permission_profile, &permissions); + assert_eq!(spawn.workspace_roots, std::slice::from_ref(&cwd)); +} diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index fe86a3c16307..a441dee8e11d 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -13,13 +13,14 @@ use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEvent; -#[cfg(unix)] +#[cfg(any(unix, windows))] use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::ProcessId; use codex_exec_server::ProcessSignal; use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteStatus; +use codex_protocol::config_types::WindowsSandboxLevel; #[cfg(unix)] use codex_protocol::models::PermissionProfile; #[cfg(unix)] @@ -34,6 +35,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; #[cfg(unix)] use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -699,6 +701,65 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re Ok(()) } +async fn assert_remote_windows_sandbox_process_write() -> Result<()> { + let context = create_process_context(/*use_remote*/ true).await?; + let workspace = TempDir::new()?; + let blocked_file = workspace.path().join("blocked.txt"); + let cwd = PathUri::from_host_native_path(workspace.path())?; + let mut sandbox = FileSystemSandboxContext::from_legacy_sandbox_policy( + SandboxPolicy::new_read_only_policy(), + cwd.clone(), + )?; + sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; + + let session = match context + .backend + .start(ExecParams { + process_id: ProcessId::from("proc-windows-sandbox-stdin"), + argv: vec![ + r"C:\Windows\System32\cmd.exe".to_string(), + "/D".to_string(), + "/V:ON".to_string(), + "/S".to_string(), + "/C".to_string(), + format!( + "set /P line= & echo blocked > \"{}\" & echo from-stdin:!line!", + blocked_file.display() + ), + ], + cwd, + env_policy: /*env_policy*/ None, + env: Default::default(), + tty: false, + pipe_stdin: true, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + }) + .await + { + Ok(session) => session, + Err(err) => return Err(err.into()), + }; + + let write_response = session.process.write(b"hello\n".to_vec()).await?; + assert_eq!(write_response.status, WriteStatus::Accepted); + let StartedExecProcess { process, .. } = session; + let wake_rx = process.subscribe_wake(); + let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; + + assert!( + output.contains("from-stdin:hello"), + "unexpected output: {output:?}" + ); + assert_eq!(exit_code, Some(0)); + assert!(closed); + assert!(!blocked_file.exists()); + Ok(()) +} + async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) -> Result<()> { let context = create_process_context(use_remote).await?; let process_id = "proc-stdin-closed".to_string(); @@ -1098,6 +1159,13 @@ async fn exec_process_write_then_read_without_tty(use_remote: bool) -> Result<() assert_exec_process_write_then_read_without_tty(use_remote).await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg_attr(not(windows), ignore = "Windows-only exec-server sandbox process test")] +#[serial_test::serial(remote_exec_server)] +async fn remote_windows_sandbox_process_accepts_process_write() -> Result<()> { + assert_remote_windows_sandbox_process_write().await +} + #[test_case(false ; "local")] #[test_case(true ; "remote")] #[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")] diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 3185d782baa6..2c0885729b4e 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -13,10 +13,12 @@ doctest = false workspace = true [dependencies] +anyhow = { workspace = true } codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-utils-pty = { workspace = true } codex-windows-sandbox = { workspace = true } dunce = { workspace = true } libc = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index c9109146bde2..c47d7ed3e667 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -6,6 +6,7 @@ mod manager; pub mod policy_transforms; #[cfg(target_os = "macos")] pub mod seatbelt; +mod spawn; mod windows; #[cfg(target_os = "linux")] @@ -25,6 +26,9 @@ pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; pub use manager::with_managed_mitm_ca_readable_root; +pub use spawn::SpawnRequest; +pub use spawn::WindowsSandboxSpawnRequest; +pub use spawn::spawn_process; pub use windows::WindowsSandboxFilesystemOverrides; pub use windows::permission_profile_supports_windows_restricted_token_sandbox; pub use windows::resolve_windows_elevated_filesystem_overrides; diff --git a/codex-rs/sandboxing/src/spawn.rs b/codex-rs/sandboxing/src/spawn.rs new file mode 100644 index 000000000000..ae9a6812a529 --- /dev/null +++ b/codex-rs/sandboxing/src/spawn.rs @@ -0,0 +1,125 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; + +use crate::SandboxType; +use crate::WindowsSandboxFilesystemOverrides; +use crate::WindowsSandboxProxySettingsMode; + +/// Windows-specific inputs for an executor-native process spawn. +pub struct WindowsSandboxSpawnRequest<'a> { + pub permission_profile: &'a PermissionProfile, + pub workspace_roots: &'a [AbsolutePathBuf], + pub windows_sandbox_level: WindowsSandboxLevel, + pub proxy_enforced: bool, + pub proxy_settings_mode: WindowsSandboxProxySettingsMode, + pub filesystem_overrides: Option<&'a WindowsSandboxFilesystemOverrides>, + pub use_private_desktop: bool, +} + +/// Executor-native process launch request shared by local and exec-server execution. +pub struct SpawnRequest<'a> { + pub command: &'a [String], + pub cwd: &'a Path, + pub env: &'a HashMap, + pub arg0: &'a Option, + pub sandbox: SandboxType, + pub windows_sandbox: Option>, + pub tty: bool, + pub stdin_open: bool, + pub inherited_fds: &'a [i32], +} + +/// Spawn a process using the backend selected by the prepared sandbox request. +pub async fn spawn_process(request: SpawnRequest<'_>) -> Result { + if request.sandbox == SandboxType::WindowsRestrictedToken { + #[cfg(target_os = "windows")] + { + let windows = request + .windows_sandbox + .context("missing Windows sandbox spawn request")?; + let codex_home = codex_utils_home_dir::find_codex_home() + .context("windows sandbox: failed to resolve codex_home")?; + let empty_paths = &[]; + let overrides = windows.filesystem_overrides; + + return codex_windows_sandbox::spawn_windows_sandbox_session_for_level( + codex_windows_sandbox::WindowsSandboxSessionRequest { + permission_profile: windows.permission_profile, + workspace_roots: windows.workspace_roots, + codex_home: codex_home.as_path(), + command: request.command.to_vec(), + cwd: request.cwd, + env_map: request.env.clone(), + windows_sandbox_level: windows.windows_sandbox_level, + proxy_enforced: windows.proxy_enforced, + proxy_settings_mode: windows.proxy_settings_mode, + timeout_ms: None, + read_roots_override: overrides + .and_then(|value| value.read_roots_override.as_deref()), + read_roots_include_platform_defaults: overrides + .is_some_and(|value| value.read_roots_include_platform_defaults), + write_roots_override: overrides + .and_then(|value| value.write_roots_override.as_deref()), + deny_read_paths_override: overrides.map_or(empty_paths, |value| { + value.additional_deny_read_paths.as_slice() + }), + deny_write_paths_override: overrides.map_or(empty_paths, |value| { + value.additional_deny_write_paths.as_slice() + }), + tty: request.tty, + stdin_open: request.stdin_open, + use_private_desktop: windows.use_private_desktop, + }, + ) + .await; + } + + #[cfg(not(target_os = "windows"))] + anyhow::bail!("Windows sandbox process spawn is unavailable on this platform"); + } + + let (program, args) = request + .command + .split_first() + .context("missing program for process spawn")?; + if request.tty { + codex_utils_pty::pty::spawn_process( + program, + args, + request.cwd, + request.env, + request.arg0, + TerminalSize::default(), + request.inherited_fds, + ) + .await + } else if request.stdin_open { + codex_utils_pty::pipe::spawn_process( + program, + args, + request.cwd, + request.env, + request.arg0, + request.inherited_fds, + ) + .await + } else { + codex_utils_pty::pipe::spawn_process_no_stdin( + program, + args, + request.cwd, + request.env, + request.arg0, + request.inherited_fds, + ) + .await + } +} diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 3a9b62d9b7b0..d862493b99eb 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -263,31 +263,31 @@ async fn spawn_process_with_stdin_mode( }) } -/// Spawn a process using regular pipes (no PTY), returning handles for stdin, split output, and exit. +/// Spawn a process using regular pipes and preserve selected inherited file +/// descriptors across exec on Unix. pub async fn spawn_process( program: &str, args: &[String], cwd: &Path, env: &HashMap, arg0: &Option, + inherited_fds: &[i32], ) -> Result { - spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped, &[]).await -} - -/// Spawn a process using regular pipes, but close stdin immediately. -pub async fn spawn_process_no_stdin( - program: &str, - args: &[String], - cwd: &Path, - env: &HashMap, - arg0: &Option, -) -> Result { - spawn_process_no_stdin_with_inherited_fds(program, args, cwd, env, arg0, &[]).await + spawn_process_with_stdin_mode( + program, + args, + cwd, + env, + arg0, + PipeStdinMode::Piped, + inherited_fds, + ) + .await } /// Spawn a process using regular pipes, close stdin immediately, and preserve /// selected inherited file descriptors across exec on Unix. -pub async fn spawn_process_no_stdin_with_inherited_fds( +pub async fn spawn_process_no_stdin( program: &str, args: &[String], cwd: &Path, diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs index f2690c4deb4e..807b33a2a14a 100644 --- a/codex-rs/utils/pty/src/pty.rs +++ b/codex-rs/utils/pty/src/pty.rs @@ -122,7 +122,8 @@ fn platform_native_pty_system() -> Box { } } -/// Spawn a process attached to a PTY, returning handles for stdin, split output, and exit. +/// Spawn a process attached to a PTY, preserving selected inherited file +/// descriptors across exec on Unix. pub async fn spawn_process( program: &str, args: &[String], @@ -130,19 +131,6 @@ pub async fn spawn_process( env: &HashMap, arg0: &Option, size: TerminalSize, -) -> Result { - spawn_process_with_inherited_fds(program, args, cwd, env, arg0, size, &[]).await -} - -/// Spawn a process attached to a PTY, preserving any inherited file -/// descriptors listed in `inherited_fds` across exec on Unix. -pub async fn spawn_process_with_inherited_fds( - program: &str, - args: &[String], - cwd: &Path, - env: &HashMap, - arg0: &Option, - size: TerminalSize, inherited_fds: &[i32], ) -> Result { if program.is_empty() { diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index f9255176f461..8d1832d32859 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -7,10 +7,6 @@ use crate::ProcessDriver; use crate::SpawnedProcess; use crate::TerminalSize; use crate::combine_output_receivers; -#[cfg(unix)] -use crate::pipe::spawn_process_no_stdin_with_inherited_fds; -#[cfg(unix)] -use crate::pty::spawn_process_with_inherited_fds; use crate::spawn_from_driver; use crate::spawn_pipe_process; use crate::spawn_pipe_process_no_stdin; @@ -365,6 +361,7 @@ async fn pty_python_repl_emits_output_and_exits() -> anyhow::Result<()> { &env_map, &None, TerminalSize::default(), + &[], ) .await?; let (session, mut output_rx, exit_rx) = combine_spawned_output(spawned); @@ -421,7 +418,7 @@ async fn pipe_process_round_trips_stdin() -> anyhow::Result<()> { ) }; let env_map: HashMap = std::env::vars().collect(); - let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None, &[]).await?; let (session, output_rx, exit_rx) = combine_spawned_output(spawned); let writer = session.writer_sender(); let newline = if cfg!(windows) { "\r\n" } else { "\n" }; @@ -454,7 +451,7 @@ async fn pipe_process_detaches_from_parent_session() -> anyhow::Result<()> { let env_map: HashMap = std::env::vars().collect(); let script = "echo $$; sleep 0.2"; let (program, args) = shell_command(script); - let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None, &[]).await?; let (_session, mut output_rx, exit_rx) = combine_spawned_output(spawned); let pid_bytes = @@ -493,8 +490,15 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { let (pipe_program, pipe_args) = shell_command(&echo_sleep_command("pipe_ok")); let (pty_program, pty_args) = shell_command(&echo_sleep_command("pty_ok")); - let pipe = - spawn_pipe_process(&pipe_program, &pipe_args, Path::new("."), &env_map, &None).await?; + let pipe = spawn_pipe_process( + &pipe_program, + &pipe_args, + Path::new("."), + &env_map, + &None, + &[], + ) + .await?; let pty = spawn_pty_process( &pty_program, &pty_args, @@ -502,6 +506,7 @@ async fn pipe_and_pty_share_interface() -> anyhow::Result<()> { &env_map, &None, TerminalSize::default(), + &[], ) .await?; let (_pipe_session, pipe_output_rx, pipe_exit_rx) = combine_spawned_output(pipe); @@ -537,7 +542,7 @@ async fn pipe_drains_stderr_without_stdout_activity() -> anyhow::Result<()> { let script = "import sys\nchunk = 'E' * 65536\nfor _ in range(64):\n sys.stderr.write(chunk)\n sys.stderr.flush()\n"; let args = vec!["-c".to_string(), script.to_string()]; let env_map: HashMap = std::env::vars().collect(); - let spawned = spawn_pipe_process(&python, &args, Path::new("."), &env_map, &None).await?; + let spawned = spawn_pipe_process(&python, &args, Path::new("."), &env_map, &None, &[]).await?; let (_session, output_rx, exit_rx) = combine_spawned_output(spawned); let (output, code) = collect_output_until_exit(output_rx, exit_rx, /*timeout_ms*/ 10_000).await; @@ -553,7 +558,7 @@ async fn pipe_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()> let env_map: HashMap = std::env::vars().collect(); let (program, args) = shell_command(&split_stdout_stderr_command()); let spawned = - spawn_pipe_process_no_stdin(&program, &args, Path::new("."), &env_map, &None).await?; + spawn_pipe_process_no_stdin(&program, &args, Path::new("."), &env_map, &None, &[]).await?; let SpawnedProcess { session: _session, stdout_rx, @@ -747,7 +752,7 @@ async fn pipe_terminate_aborts_detached_readers() -> anyhow::Result<()> { let script = "setsid sh -c 'i=0; while [ $i -lt 200 ]; do echo tick; sleep 0.01; i=$((i+1)); done' &"; let (program, args) = shell_command(script); - let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None).await?; + let spawned = spawn_pipe_process(&program, &args, Path::new("."), &env_map, &None, &[]).await?; let (session, mut output_rx, _exit_rx) = combine_spawned_output(spawned); let _ = tokio::time::timeout(tokio::time::Duration::from_millis(500), output_rx.recv()) @@ -787,6 +792,7 @@ async fn pty_terminate_kills_background_children_in_same_process_group() -> anyh &env_map, &None, TerminalSize::default(), + &[], ) .await?; let (session, mut output_rx, _exit_rx) = combine_spawned_output(spawned); @@ -841,7 +847,7 @@ async fn pty_spawn_can_preserve_inherited_fds() -> anyhow::Result<()> { ); let script = "printf __preserved__ >\"/dev/fd/$PRESERVED_FD\""; - let spawned = spawn_process_with_inherited_fds( + let spawned = spawn_pty_process( "/bin/sh", &["-c".to_string(), script.to_string()], Path::new("."), @@ -893,7 +899,7 @@ async fn pty_preserving_inherited_fds_keeps_python_repl_running() -> anyhow::Res preserved_fd.as_raw_fd().to_string(), ); - let spawned = spawn_process_with_inherited_fds( + let spawned = spawn_pty_process( &python, &[], Path::new("."), @@ -959,7 +965,7 @@ async fn pty_spawn_with_inherited_fds_reports_exec_failures() -> anyhow::Result< let write_end = unsafe { std::fs::File::from_raw_fd(fds[1]) }; let env_map: HashMap = std::env::vars().collect(); - let spawn_result = spawn_process_with_inherited_fds( + let spawn_result = spawn_pty_process( "/definitely/missing/command", &[], Path::new("."), @@ -1008,7 +1014,7 @@ async fn pty_spawn_with_inherited_fds_supports_resize() -> anyhow::Result<()> { let env_map: HashMap = std::env::vars().collect(); let script = "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\""; - let spawned = spawn_process_with_inherited_fds( + let spawned = spawn_pty_process( "/bin/sh", &["-c".to_string(), script.to_string()], Path::new("."), @@ -1079,7 +1085,7 @@ async fn pipe_spawn_no_stdin_can_preserve_inherited_fds() -> anyhow::Result<()> ); let script = "printf __pipe_preserved__ >\"/dev/fd/$PRESERVED_FD\""; - let spawned = spawn_process_no_stdin_with_inherited_fds( + let spawned = spawn_pipe_process_no_stdin( "/bin/sh", &["-c".to_string(), script.to_string()], Path::new("."), diff --git a/codex-rs/utils/pty/src/windows_tests.rs b/codex-rs/utils/pty/src/windows_tests.rs index 6923f266cbda..17829cb85a43 100644 --- a/codex-rs/utils/pty/src/windows_tests.rs +++ b/codex-rs/utils/pty/src/windows_tests.rs @@ -74,6 +74,7 @@ async fn conpty_delivers_input_to_foreground_children() -> anyhow::Result<()> { &env, /*arg0*/ &None, TerminalSize::default(), + &[], ) .await?; let (session, mut output_rx, exit_rx) = combine_spawned_output(spawned); @@ -126,6 +127,7 @@ async fn conpty_ctrl_c_interrupts_powershell_foreground_child() -> anyhow::Resul &env, /*arg0*/ &None, TerminalSize::default(), + &[], ) .await?; let (session, mut output_rx, exit_rx) = combine_spawned_output(spawned);