diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 396b7701be..0c841693d3 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -314,7 +314,11 @@ pub fn build_exec_request( windows_sandbox_level, windows_sandbox_private_desktop, }) - .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) + .map(|request| { + let windows_sandbox_policy_cwd = AbsolutePathBuf::try_from(sandbox_cwd.to_path_buf()) + .unwrap_or_else(|_| request.cwd.clone()); + ExecRequest::from_sandbox_exec_request(request, options, windows_sandbox_policy_cwd) + }) .map_err(CodexErr::from)?; let use_windows_elevated_backend = windows_sandbox_uses_elevated_backend( exec_req.windows_sandbox_level, @@ -357,6 +361,7 @@ pub(crate) async fn execute_exec_request( expiration, capture_policy, sandbox, + windows_sandbox_policy_cwd: _, windows_sandbox_level, windows_sandbox_private_desktop, sandbox_policy, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d4e83a6637..09e31274e7 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -49,6 +49,7 @@ pub struct ExecRequest { pub expiration: ExecExpiration, pub capture_policy: ExecCapturePolicy, pub sandbox: SandboxType, + pub windows_sandbox_policy_cwd: AbsolutePathBuf, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, pub sandbox_policy: SandboxPolicy, @@ -75,6 +76,7 @@ impl ExecRequest { network_sandbox_policy: NetworkSandboxPolicy, arg0: Option, ) -> Self { + let windows_sandbox_policy_cwd = cwd.clone(); Self { command, cwd, @@ -84,6 +86,7 @@ impl ExecRequest { expiration, capture_policy, sandbox, + windows_sandbox_policy_cwd, windows_sandbox_level, windows_sandbox_private_desktop, sandbox_policy, @@ -97,6 +100,7 @@ impl ExecRequest { pub(crate) fn from_sandbox_exec_request( request: SandboxExecRequest, options: ExecOptions, + windows_sandbox_policy_cwd: AbsolutePathBuf, ) -> Self { let SandboxExecRequest { command, @@ -134,6 +138,7 @@ impl ExecRequest { expiration, capture_policy, sandbox, + windows_sandbox_policy_cwd, windows_sandbox_level, windows_sandbox_private_desktop, sandbox_policy, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 7942d8d28a..5587dd46f8 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -171,6 +171,7 @@ pub(crate) async fn execute_user_shell_command( expiration: USER_SHELL_TIMEOUT_MS.into(), capture_policy: ExecCapturePolicy::ShellTool, sandbox: SandboxType::None, + windows_sandbox_policy_cwd: cwd.clone(), windows_sandbox_level: turn_context.windows_sandbox_level, windows_sandbox_private_desktop: turn_context .config diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index d38ecae44c..c91d0fec51 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1091,7 +1091,11 @@ impl JsReplManager { .windows_sandbox_private_desktop, }) .map(|request| { - crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options) + crate::sandboxing::ExecRequest::from_sandbox_exec_request( + request, + options, + turn.cwd.clone(), + ) }) .map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 9d3b7c5f27..2e88594193 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -134,6 +134,7 @@ pub(super) async fn try_run_zsh_fork( expiration: _sandbox_expiration, capture_policy: _capture_policy, sandbox, + windows_sandbox_policy_cwd: sandbox_policy_cwd, windows_sandbox_level, windows_sandbox_private_desktop: _windows_sandbox_private_desktop, sandbox_policy, @@ -161,7 +162,7 @@ pub(super) async fn try_run_zsh_fork( network: sandbox_network, windows_sandbox_level, arg0, - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd, codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; @@ -785,6 +786,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { expiration: ExecExpiration::Cancellation(cancel_rx), capture_policy: ExecCapturePolicy::ShellTool, sandbox: self.sandbox, + windows_sandbox_policy_cwd: self.sandbox_policy_cwd.clone(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, sandbox_policy: self.sandbox_policy.clone(), @@ -924,8 +926,11 @@ impl CoreShellCommandExecutor { windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, })?; - let mut exec_request = - crate::sandboxing::ExecRequest::from_sandbox_exec_request(exec_request, options); + let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( + exec_request, + options, + self.sandbox_policy_cwd.clone(), + ); if let Some(network) = exec_request.network.as_ref() { network.apply_to_env(&mut exec_request.env); } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 87bef4617c..a40fa0fed0 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -376,7 +376,16 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map(|request| { - crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options) + let windows_sandbox_policy_cwd = + codex_utils_absolute_path::AbsolutePathBuf::try_from( + self.sandbox_cwd.to_path_buf(), + ) + .unwrap_or_else(|_| request.cwd.clone()); + crate::sandboxing::ExecRequest::from_sandbox_exec_request( + request, + options, + windows_sandbox_policy_cwd, + ) }) } } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5181359698..ef1f3bd81c 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -659,6 +659,60 @@ impl UnifiedExecProcessManager { environment: &codex_exec_server::Environment, ) -> Result { let inherited_fds = spawn_lifecycle.inherited_fds(); + + #[cfg(target_os = "windows")] + if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { + let policy_json = serde_json::to_string(&request.sandbox_policy).map_err(|err| { + UnifiedExecError::create_process(format!( + "failed to serialize Windows sandbox policy: {err}" + )) + })?; + let codex_home = crate::config::find_codex_home().map_err(|err| { + UnifiedExecError::create_process(format!( + "windows sandbox: failed to resolve codex_home: {err}" + )) + })?; + let spawned = match request.windows_sandbox_level { + codex_protocol::config_types::WindowsSandboxLevel::Elevated => { + codex_windows_sandbox::spawn_windows_sandbox_session_elevated( + policy_json.as_str(), + request.windows_sandbox_policy_cwd.as_path(), + codex_home.as_ref(), + request.command.clone(), + request.cwd.as_path(), + request.env.clone(), + None, + 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( + policy_json.as_str(), + request.windows_sandbox_policy_cwd.as_path(), + codex_home.as_ref(), + request.command.clone(), + request.cwd.as_path(), + request.env.clone(), + None, + 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( 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 2c829cdd0e..fac05f2b4b 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -67,12 +67,13 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() { #[test] fn exec_server_params_use_env_policy_overlay_contract() { + let cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir() + .expect("current dir") + .try_into() + .expect("absolute path"); let request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], - cwd: std::env::current_dir() - .expect("current dir") - .try_into() - .expect("absolute path"), + cwd: cwd.clone(), env: HashMap::from([ ("HOME".to_string(), "/client-home".to_string()), ("PATH".to_string(), "/sandbox-path".to_string()), @@ -95,6 +96,7 @@ fn exec_server_params_use_env_policy_overlay_contract() { expiration: crate::exec::ExecExpiration::DefaultTimeout, capture_policy: crate::exec::ExecCapturePolicy::ShellTool, sandbox: codex_sandboxing::SandboxType::None, + windows_sandbox_policy_cwd: cwd, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 87898ddf71..f524156651 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -13,6 +13,8 @@ pub const DEFAULT_OUTPUT_BYTES_CAP: usize = 1024 * 1024; pub use pipe::spawn_process as spawn_pipe_process; /// Spawn a non-interactive process using regular pipes, but close stdin immediately. pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin; +/// Driver-backed process adapter used by integrations with their own process transport. +pub use process::ProcessDriver; /// Handle for interacting with a spawned process (PTY or pipe). pub use process::ProcessHandle; /// Bundle of process handles plus split output and exit receivers returned by spawn helpers. @@ -21,6 +23,8 @@ pub use process::SpawnedProcess; pub use process::TerminalSize; /// Combine stdout/stderr receivers into a single broadcast receiver. pub use process::combine_output_receivers; +/// Adapt an externally-driven process into the standard spawned-process handle. +pub use process::spawn_from_driver; /// Backwards-compatible alias for ProcessHandle. pub type ExecCommandSession = ProcessHandle; /// Backwards-compatible alias for SpawnedProcess. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 4962a22863..541a2ecf2f 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -234,6 +234,7 @@ async fn spawn_process_with_stdin_mode( exit_status, exit_code, /*pty_handles*/ None, + /*resizer*/ None, ); Ok(SpawnedProcess { diff --git a/codex-rs/utils/pty/src/process.rs b/codex-rs/utils/pty/src/process.rs index 8459f2b68f..a171cdd7af 100644 --- a/codex-rs/utils/pty/src/process.rs +++ b/codex-rs/utils/pty/src/process.rs @@ -13,6 +13,7 @@ use portable_pty::SlavePty; use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; +use tokio::sync::watch; use tokio::task::AbortHandle; use tokio::task::JoinHandle; @@ -69,6 +70,10 @@ impl fmt::Debug for PtyHandles { } } +/// Callback used by driver-backed sessions to resize a PTY-like backend when +/// there is no local `PtyHandles` instance to resize directly. +type ResizeFn = Box anyhow::Result<()> + Send>; + /// Handle for driving an interactive process (PTY or pipe). pub struct ProcessHandle { writer_tx: StdMutex>>>, @@ -82,6 +87,9 @@ pub struct ProcessHandle { // PtyHandles must be preserved because the process will receive Control+C if the // slave is closed _pty_handles: StdMutex>, + // Optional resize hook for driver-backed sessions that proxy PTY control to + // another backend instead of owning local PTY handles. + resizer: StdMutex>, } impl fmt::Debug for ProcessHandle { @@ -102,6 +110,7 @@ impl ProcessHandle { exit_status: Arc, exit_code: Arc>>, pty_handles: Option, + resizer: Option, ) -> Self { Self { writer_tx: StdMutex::new(Some(writer_tx)), @@ -113,6 +122,7 @@ impl ProcessHandle { exit_status, exit_code, _pty_handles: StdMutex::new(pty_handles), + resizer: StdMutex::new(resizer), } } @@ -141,17 +151,28 @@ impl ProcessHandle { /// Resize the PTY in character cells. pub fn resize(&self, size: TerminalSize) -> anyhow::Result<()> { - let handles = self - ._pty_handles + { + let handles = self + ._pty_handles + .lock() + .map_err(|_| anyhow!("failed to lock PTY handles"))?; + if let Some(handles) = handles.as_ref() { + return match &handles._master { + PtyMasterHandle::Resizable(master) => master.resize(size.into()), + #[cfg(unix)] + PtyMasterHandle::Opaque { raw_fd, .. } => resize_raw_pty(*raw_fd, size), + }; + } + } + + let mut resizer = self + .resizer .lock() - .map_err(|_| anyhow!("failed to lock PTY handles"))?; - let handles = handles - .as_ref() - .ok_or_else(|| anyhow!("process is not attached to a PTY"))?; - match &handles._master { - PtyMasterHandle::Resizable(master) => master.resize(size.into()), - #[cfg(unix)] - PtyMasterHandle::Opaque { raw_fd, .. } => resize_raw_pty(*raw_fd, size), + .map_err(|_| anyhow!("failed to lock PTY resizer"))?; + if let Some(resizer) = resizer.as_mut() { + resizer(size) + } else { + Err(anyhow!("process is not attached to a PTY")) } } @@ -205,6 +226,20 @@ impl Drop for ProcessHandle { } } +/// Adapts a closure into a `ChildTerminator` implementation. +struct ClosureTerminator { + inner: Option>, +} + +impl ChildTerminator for ClosureTerminator { + fn kill(&mut self) -> io::Result<()> { + if let Some(inner) = self.inner.as_mut() { + (inner)(); + } + Ok(()) + } +} + #[cfg(unix)] fn resize_raw_pty(raw_fd: RawFd, size: TerminalSize) -> anyhow::Result<()> { let mut winsize = libc::winsize { @@ -263,3 +298,113 @@ pub struct SpawnedProcess { pub stderr_rx: mpsc::Receiver>, pub exit_rx: oneshot::Receiver, } + +/// Driver-backed process handles for non-standard spawn backends. +pub struct ProcessDriver { + pub writer_tx: mpsc::Sender>, + pub stdout_rx: broadcast::Receiver>, + pub stderr_rx: Option>>, + pub exit_rx: oneshot::Receiver, + pub terminator: Option>, + pub writer_handle: Option>, + pub resizer: Option, +} + +/// Build a `SpawnedProcess` from a driver that supplies stdin/output/exit channels. +pub fn spawn_from_driver(driver: ProcessDriver) -> SpawnedProcess { + let ProcessDriver { + writer_tx, + stdout_rx: stdout_driver_rx, + stderr_rx: mut stderr_driver_rx, + exit_rx, + terminator, + writer_handle, + resizer, + } = driver; + + let (stdout_tx, stdout_rx) = mpsc::channel::>(256); + let (stderr_tx, stderr_rx) = mpsc::channel::>(256); + let (exit_seen_tx, exit_seen_rx) = watch::channel(false); + let spawn_stream_reader = + |mut output_rx: broadcast::Receiver>, + output_tx: mpsc::Sender>, + mut exit_seen_rx: watch::Receiver| { + tokio::spawn(async move { + let mut process_exited = false; + loop { + let recv_result = if process_exited { + match tokio::time::timeout( + std::time::Duration::from_millis(200), + output_rx.recv(), + ) + .await + { + Ok(result) => result, + Err(_) => break, + } + } else { + tokio::select! { + _ = exit_seen_rx.changed() => { + process_exited = *exit_seen_rx.borrow(); + continue; + } + result = output_rx.recv() => result, + } + }; + match recv_result { + Ok(chunk) => { + if output_tx.send(chunk).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }) + }; + let reader_handle = spawn_stream_reader(stdout_driver_rx, stdout_tx, exit_seen_rx.clone()); + let stderr_reader_handle = stderr_driver_rx + .take() + .map(|rx| spawn_stream_reader(rx, stderr_tx, exit_seen_rx)); + + let writer_handle = writer_handle.unwrap_or_else(|| tokio::spawn(async {})); + + let (exit_tx, exit_rx_out) = oneshot::channel::(); + let exit_status = Arc::new(AtomicBool::new(false)); + let wait_exit_status = Arc::clone(&exit_status); + let exit_code = Arc::new(StdMutex::new(None)); + let wait_exit_code = Arc::clone(&exit_code); + let wait_handle = tokio::spawn(async move { + let code = exit_rx.await.unwrap_or(-1); + wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst); + if let Ok(mut guard) = wait_exit_code.lock() { + *guard = Some(code); + } + let _ = exit_seen_tx.send(true); + let _ = exit_tx.send(code); + }); + + let handle = ProcessHandle::new( + writer_tx, + Box::new(ClosureTerminator { inner: terminator }), + reader_handle, + stderr_reader_handle + .map(|handle| handle.abort_handle()) + .into_iter() + .collect(), + writer_handle, + wait_handle, + exit_status, + exit_code, + /*pty_handles*/ None, + resizer, + ); + + SpawnedProcess { + session: handle, + stdout_rx, + stderr_rx, + exit_rx: exit_rx_out, + } +} diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs index 4ae6facb7d..45c587b328 100644 --- a/codex-rs/utils/pty/src/pty.rs +++ b/codex-rs/utils/pty/src/pty.rs @@ -242,6 +242,7 @@ async fn spawn_process_portable( exit_status, exit_code, Some(handles), + /*resizer*/ None, ); Ok(SpawnedProcess { @@ -395,6 +396,7 @@ async fn spawn_process_preserving_fds( exit_status, exit_code, Some(handles), + /*resizer*/ None, ); Ok(SpawnedProcess { diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index c614f357ff..c7bfa512a3 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -3,6 +3,7 @@ use std::path::Path; use pretty_assertions::assert_eq; +use crate::ProcessDriver; use crate::SpawnedProcess; use crate::TerminalSize; use crate::combine_output_receivers; @@ -10,6 +11,7 @@ use crate::combine_output_receivers; 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; use crate::spawn_pty_process; @@ -589,6 +591,103 @@ async fn pipe_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn driver_backed_process_can_expose_split_stdout_and_stderr() -> anyhow::Result<()> { + let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::>(1); + let (stdout_tx, stdout_driver_rx) = tokio::sync::broadcast::channel::>(8); + let (stderr_tx, stderr_driver_rx) = tokio::sync::broadcast::channel::>(8); + let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::(); + + let spawned = spawn_from_driver(ProcessDriver { + writer_tx, + stdout_rx: stdout_driver_rx, + stderr_rx: Some(stderr_driver_rx), + exit_rx, + terminator: None, + writer_handle: None, + resizer: None, + }); + + let SpawnedProcess { + session: _session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + let stdout_task = tokio::spawn(async move { collect_split_output(stdout_rx).await }); + let stderr_task = tokio::spawn(async move { collect_split_output(stderr_rx).await }); + + stdout_tx.send(b"driver-out".to_vec())?; + stderr_tx.send(b"driver-err".to_vec())?; + drop(stdout_tx); + drop(stderr_tx); + exit_tx.send(0).expect("send exit code"); + + let timeout = tokio::time::Duration::from_secs(2); + let code = tokio::time::timeout(timeout, exit_rx) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for driver exit"))? + .unwrap_or(-1); + let stdout = tokio::time::timeout(timeout, stdout_task) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting to drain driver stdout"))??; + let stderr = tokio::time::timeout(timeout, stderr_task) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting to drain driver stderr"))??; + + assert_eq!(stdout, b"driver-out".to_vec()); + assert_eq!(stderr, b"driver-err".to_vec()); + assert_eq!(code, 0); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn driver_backed_process_can_resize_via_resizer_hook() -> anyhow::Result<()> { + let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::>(1); + let (_stdout_tx, stdout_driver_rx) = tokio::sync::broadcast::channel::>(8); + let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::(); + let (size_tx, size_rx) = tokio::sync::oneshot::channel::(); + + let size_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(size_tx))); + let spawned = spawn_from_driver(ProcessDriver { + writer_tx, + stdout_rx: stdout_driver_rx, + stderr_rx: None, + exit_rx, + terminator: None, + writer_handle: None, + resizer: Some(Box::new(move |size| { + if let Ok(mut guard) = size_tx.lock() + && let Some(size_tx) = guard.take() + { + let _ = size_tx.send(size); + } + Ok(()) + })), + }); + + spawned.session.resize(TerminalSize { + rows: 40, + cols: 120, + })?; + exit_tx.send(0).expect("send exit code"); + + let resized = tokio::time::timeout(tokio::time::Duration::from_secs(2), size_rx) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for resize"))? + .expect("receive resized terminal size"); + assert_eq!( + resized, + TerminalSize { + rows: 40, + cols: 120 + } + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pipe_terminate_aborts_detached_readers() -> anyhow::Result<()> { if !setsid_available() { diff --git a/codex-rs/utils/pty/src/win/psuedocon.rs b/codex-rs/utils/pty/src/win/psuedocon.rs index 3476665633..f235c70278 100644 --- a/codex-rs/utils/pty/src/win/psuedocon.rs +++ b/codex-rs/utils/pty/src/win/psuedocon.rs @@ -118,6 +118,10 @@ fn windows_build_number() -> Option { pub struct PsuedoCon { con: HPCON, + // CreatePseudoConsole borrows these pipe handles for the lifetime of the + // pseudoconsole, so we must keep owning them until ClosePseudoConsole. + _input: FileDescriptor, + _output: FileDescriptor, } unsafe impl Send for PsuedoCon {} @@ -149,7 +153,11 @@ impl PsuedoCon { result == S_OK, "failed to create psuedo console: HRESULT {result}" ); - Ok(Self { con }) + Ok(Self { + con, + _input: input, + _output: output, + }) } pub fn resize(&self, size: COORD) -> Result<(), Error> { diff --git a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs index c7b992f518..54d1f34281 100644 --- a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs @@ -6,10 +6,8 @@ //! `tty=true`. The helpers are not tied to the IPC layer and can be reused by other //! Windows sandbox flows that need a PTY. -mod proc_thread_attr; - -use self::proc_thread_attr::ProcThreadAttributeList; use crate::desktop::LaunchDesktop; +use crate::proc_thread_attr::ProcThreadAttributeList; use crate::winutil::format_last_error; use crate::winutil::quote_windows_arg; use crate::winutil::to_wide; @@ -37,7 +35,7 @@ pub struct ConptyInstance { pub hpc: HANDLE, pub input_write: HANDLE, pub output_read: HANDLE, - _desktop: LaunchDesktop, + desktop: Option, } impl Drop for ConptyInstance { @@ -58,9 +56,10 @@ impl Drop for ConptyInstance { impl ConptyInstance { /// Consume the instance and return raw handles without closing them. - pub fn into_raw(self) -> (HANDLE, HANDLE, HANDLE) { + pub fn into_raw(self) -> (HANDLE, HANDLE, HANDLE, Option) { let me = std::mem::ManuallyDrop::new(self); - (me.hpc, me.input_write, me.output_read) + let desktop = unsafe { std::ptr::read(&me.desktop) }; + (me.hpc, me.input_write, me.output_read, desktop) } } @@ -68,6 +67,7 @@ impl ConptyInstance { /// /// This is public so callers that need lower-level PTY setup can build on the same /// primitive, although the common entry point is `spawn_conpty_process_as_user`. +#[allow(dead_code)] pub fn create_conpty(cols: i16, rows: i16) -> Result { let raw = RawConPty::new(cols, rows)?; let (hpc, input_write, output_read) = raw.into_raw_handles(); @@ -76,9 +76,7 @@ pub fn create_conpty(cols: i16, rows: i16) -> Result { hpc: hpc as HANDLE, input_write: input_write as HANDLE, output_read: output_read as HANDLE, - _desktop: LaunchDesktop::prepare( - /*use_private_desktop*/ false, /*logs_base_dir*/ None, - )?, + desktop: None, }) } @@ -110,7 +108,14 @@ pub fn spawn_conpty_process_as_user( let desktop = LaunchDesktop::prepare(use_private_desktop, logs_base_dir)?; si.StartupInfo.lpDesktop = desktop.startup_info_desktop(); - let conpty = create_conpty(/*cols*/ 80, /*rows*/ 24)?; + let raw = RawConPty::new(/*cols*/ 80, /*rows*/ 24)?; + let (hpc, input_write, output_read) = raw.into_raw_handles(); + let conpty = ConptyInstance { + hpc: hpc as HANDLE, + input_write: input_write as HANDLE, + output_read: output_read as HANDLE, + desktop: Some(desktop), + }; let mut attrs = ProcThreadAttributeList::new(/*attr_count*/ 1)?; attrs.set_pseudoconsole(conpty.hpc)?; si.lpAttributeList = attrs.as_mut_ptr(); @@ -142,7 +147,5 @@ pub fn spawn_conpty_process_as_user( env_block.len() )); } - let mut conpty = conpty; - conpty._desktop = desktop; Ok((pi, conpty)) } diff --git a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs index 9cb59e18dc..dcca40abff 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs @@ -15,10 +15,12 @@ use anyhow::Result; use codex_windows_sandbox::ErrorPayload; use codex_windows_sandbox::ExitPayload; use codex_windows_sandbox::FramedMessage; +use codex_windows_sandbox::LaunchDesktop; use codex_windows_sandbox::Message; use codex_windows_sandbox::OutputPayload; use codex_windows_sandbox::OutputStream; use codex_windows_sandbox::PipeSpawnHandles; +use codex_windows_sandbox::ResizePayload; use codex_windows_sandbox::SandboxPolicy; use codex_windows_sandbox::SpawnReady; use codex_windows_sandbox::SpawnRequest; @@ -56,7 +58,9 @@ use windows_sys::Win32::Storage::FileSystem::CreateFileW; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +use windows_sys::Win32::System::Console::COORD; use windows_sys::Win32::System::Console::ClosePseudoConsole; +use windows_sys::Win32::System::Console::ResizePseudoConsole; use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; @@ -86,6 +90,8 @@ struct IpcSpawnedProcess { stderr_handle: HANDLE, stdin_handle: Option, hpc_handle: Option, + _desktop_owner: Option, + _pipe_handles: Option, } unsafe fn create_job_kill_on_close() -> Result { @@ -166,7 +172,7 @@ fn effective_cwd(req_cwd: &Path, log_dir: Option<&Path>) -> PathBuf { Err(err) => { log_note( &format!( - "junction: read_acl_mutex_exists failed: {err}; assuming read ACL helper is running" + "junction: failed to probe ACL mutex state: {err}; defaulting to junction cwd" ), log_dir, ); @@ -174,10 +180,6 @@ fn effective_cwd(req_cwd: &Path, log_dir: Option<&Path>) -> PathBuf { } }; if use_junction { - log_note( - "junction: read ACL helper running; using junction CWD", - log_dir, - ); cwd_junction::create_cwd_junction(req_cwd, log_dir).unwrap_or_else(|| req_cwd.to_path_buf()) } else { req_cwd.to_path_buf() @@ -187,16 +189,6 @@ fn effective_cwd(req_cwd: &Path, log_dir: Option<&Path>) -> PathBuf { fn spawn_ipc_process(req: &SpawnRequest) -> Result { let log_dir = req.codex_home.clone(); hide_current_user_profile_dir(req.codex_home.as_path()); - log_note( - &format!( - "runner start cwd={} cmd={:?} real_codex_home={}", - req.cwd.display(), - req.command, - req.real_codex_home.display() - ), - Some(&req.codex_home), - ); - let policy = parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?; let mut cap_psids: Vec<*mut c_void> = Vec::new(); for sid in &req.cap_sids { @@ -240,16 +232,10 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { } let effective_cwd = effective_cwd(&req.cwd, Some(log_dir.as_path())); - log_note( - &format!( - "runner: effective cwd={} (requested {})", - effective_cwd.display(), - req.cwd.display() - ), - Some(log_dir.as_path()), - ); let mut hpc_handle: Option = None; + let mut desktop_owner = None; + let mut pipe_handles = None; let (pi, stdout_handle, stderr_handle, stdin_handle) = if req.tty { let (pi, conpty) = codex_windows_sandbox::spawn_conpty_process_as_user( h_token, @@ -259,8 +245,9 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { req.use_private_desktop, Some(log_dir.as_path()), )?; - let (hpc, input_write, output_read) = conpty.into_raw(); + let (hpc, input_write, output_read, desktop) = conpty.into_raw(); hpc_handle = Some(hpc); + desktop_owner = desktop; let stdin_handle = if req.stdin_open { Some(input_write) } else { @@ -281,29 +268,29 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { } else { StdinMode::Closed }; - let pipe_handles: PipeSpawnHandles = spawn_process_with_pipes( + let spawned_pipes: PipeSpawnHandles = spawn_process_with_pipes( h_token, &req.command, &effective_cwd, &req.env, stdin_mode, StderrMode::Separate, - /*use_private_desktop*/ false, + req.use_private_desktop, + Some(log_dir.as_path()), )?; - ( - pipe_handles.process, - pipe_handles.stdout_read, - pipe_handles - .stderr_read - .unwrap_or(windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE), - pipe_handles.stdin_write, - ) + let pi = spawned_pipes.process; + let stdout_handle = spawned_pipes.stdout_read; + let stderr_handle = spawned_pipes + .stderr_read + .unwrap_or(windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE); + let stdin_handle = spawned_pipes.stdin_write; + pipe_handles = Some(spawned_pipes); + (pi, stdout_handle, stderr_handle, stdin_handle) }; unsafe { CloseHandle(h_token); } - Ok(IpcSpawnedProcess { log_dir, pi, @@ -311,6 +298,8 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { stderr_handle, stdin_handle, hpc_handle, + _desktop_owner: desktop_owner, + _pipe_handles: pipe_handles, }) } @@ -346,21 +335,17 @@ fn spawn_output_reader( fn spawn_input_loop( mut reader: File, stdin_handle: Option, + hpc_handle: Arc>>, process_handle: Arc>>, - log_dir: Option, + _log_dir: Option, ) -> std::thread::JoinHandle<()> { std::thread::spawn(move || { + let mut stdin_handle = stdin_handle; loop { let msg = match read_frame(&mut reader) { Ok(Some(v)) => v, Ok(None) => break, - Err(err) => { - log_note( - &format!("runner input read failed: {err}"), - log_dir.as_deref(), - ); - break; - } + Err(_) => break, }; match msg.message { Message::Stdin { payload } => { @@ -380,6 +365,30 @@ fn spawn_input_loop( } } } + Message::CloseStdin { .. } => { + if let Some(handle) = stdin_handle.take() { + unsafe { + CloseHandle(handle); + } + } + } + Message::Resize { + payload: ResizePayload { rows, cols }, + } => { + if let Ok(guard) = hpc_handle.lock() + && let Some(hpc) = guard.as_ref() + { + unsafe { + let _ = ResizePseudoConsole( + *hpc, + COORD { + X: cols as i16, + Y: rows as i16, + }, + ); + } + } + } Message::Terminate { .. } => { if let Ok(guard) = process_handle.lock() && let Some(handle) = guard.as_ref() @@ -450,7 +459,7 @@ pub fn main() -> Result<()> { let stdout_handle = ipc_spawn.stdout_handle; let stderr_handle = ipc_spawn.stderr_handle; let stdin_handle = ipc_spawn.stdin_handle; - let hpc_handle = ipc_spawn.hpc_handle; + let hpc_handle = Arc::new(StdMutex::new(ipc_spawn.hpc_handle)); let h_job = unsafe { create_job_kill_on_close().ok() }; if let Some(job) = h_job { @@ -474,7 +483,6 @@ pub fn main() -> Result<()> { } else { anyhow::bail!("runner spawn_ready write failed: pipe_write lock poisoned"); } { - log_note(&format!("runner spawn_ready write failed: {err}"), log_dir); let _ = send_error(&pipe_write, "spawn_failed", err.to_string()); return Err(err); } @@ -499,6 +507,7 @@ pub fn main() -> Result<()> { let _input_thread = spawn_input_loop( pipe_read, stdin_handle, + Arc::clone(&hpc_handle), Arc::clone(&process_handle), log_dir_owned, ); @@ -517,9 +526,6 @@ pub fn main() -> Result<()> { GetExitCodeProcess(pi.hProcess, &mut raw_exit); exit_code = raw_exit as i32; } - if let Some(hpc) = hpc_handle { - ClosePseudoConsole(hpc); - } if pi.hThread != 0 { CloseHandle(pi.hThread); } @@ -530,10 +536,20 @@ pub fn main() -> Result<()> { CloseHandle(job); } } - let _ = out_thread.join(); - if let Some(err_thread) = err_thread { - let _ = err_thread.join(); + + if let Ok(mut guard) = hpc_handle.lock() + && let Some(hpc) = guard.take() + { + unsafe { + ClosePseudoConsole(hpc); + } } + + let _ = out_thread.join(); + if let Some(thread) = err_thread { + let _ = thread.join(); + } + let exit_msg = FramedMessage { version: 1, message: Message::Exit { diff --git a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs index 3f3425eb0e..56e33e57ca 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs @@ -33,8 +33,8 @@ pub struct FramedMessage { /// IPC message variants exchanged between parent and runner. /// -/// `SpawnRequest`, `Stdin`, and `Terminate` are parent->runner commands. `SpawnReady`, -/// `Output`, `Exit`, and `Error` are runner->parent events/results. +/// `SpawnRequest`, `Stdin`, `CloseStdin`, `Resize`, and `Terminate` are parent->runner commands. +/// `SpawnReady`, `Output`, `Exit`, and `Error` are runner->parent events/results. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Message { @@ -42,6 +42,8 @@ pub enum Message { SpawnReady { payload: SpawnReady }, Output { payload: OutputPayload }, Stdin { payload: StdinPayload }, + CloseStdin { payload: EmptyPayload }, + Resize { payload: ResizePayload }, Exit { payload: ExitPayload }, Error { payload: ErrorPayload }, Terminate { payload: EmptyPayload }, @@ -93,6 +95,13 @@ pub struct StdinPayload { pub data_b64: String, } +/// PTY resize request sent from parent to runner. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ResizePayload { + pub rows: u16, + pub cols: u16, +} + /// Exit status sent from runner to parent. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ExitPayload { diff --git a/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs b/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs new file mode 100644 index 0000000000..bbcaec330f --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/elevated/runner_client.rs @@ -0,0 +1,213 @@ +use crate::identity::SandboxCreds; +use crate::ipc_framed::FramedMessage; +use crate::ipc_framed::Message; +use crate::ipc_framed::SpawnRequest; +use crate::ipc_framed::read_frame; +use crate::ipc_framed::write_frame; +use crate::runner_pipe::PIPE_ACCESS_INBOUND; +use crate::runner_pipe::PIPE_ACCESS_OUTBOUND; +use crate::runner_pipe::connect_pipe; +use crate::runner_pipe::create_named_pipe; +use crate::runner_pipe::find_runner_exe; +use crate::runner_pipe::pipe_pair; +use crate::winutil::quote_windows_arg; +use crate::winutil::to_wide; +use anyhow::Result; +use std::ffi::c_void; +use std::fs::File; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::path::Path; +use std::ptr; +use std::time::Duration; +use std::time::Instant; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode; +use windows_sys::Win32::System::Pipes::PeekNamedPipe; +use windows_sys::Win32::System::Threading::CreateProcessWithLogonW; +use windows_sys::Win32::System::Threading::LOGON_WITH_PROFILE; +use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; +use windows_sys::Win32::System::Threading::STARTUPINFOW; + +const RUNNER_SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(15); +const RUNNER_SPAWN_READY_POLL_INTERVAL: Duration = Duration::from_millis(50); +const RUNNER_ERROR_MODE_FLAGS: u32 = 0x0001 | 0x0002; + +pub(crate) struct RunnerTransport { + pipe_write: File, + pipe_read: File, +} + +impl RunnerTransport { + pub(crate) fn send_spawn_request(&mut self, request: SpawnRequest) -> Result<()> { + let spawn_request = FramedMessage { + version: 1, + message: Message::SpawnRequest { + payload: Box::new(request), + }, + }; + write_frame(&mut self.pipe_write, &spawn_request) + } + + pub(crate) fn read_spawn_ready(&mut self) -> Result<()> { + wait_for_complete_frame(&self.pipe_read, RUNNER_SPAWN_READY_TIMEOUT)?; + let msg = read_frame(&mut self.pipe_read)? + .ok_or_else(|| anyhow::anyhow!("runner pipe closed before spawn_ready"))?; + match msg.message { + Message::SpawnReady { .. } => Ok(()), + Message::Error { payload } => Err(anyhow::anyhow!("runner error: {}", payload.message)), + other => Err(anyhow::anyhow!( + "expected spawn_ready from runner, got {other:?}" + )), + } + } + + pub(crate) fn into_files(self) -> (File, File) { + (self.pipe_write, self.pipe_read) + } +} + +pub(crate) fn spawn_runner_transport( + codex_home: &Path, + cwd: &Path, + sandbox_creds: &SandboxCreds, + log_dir: Option<&Path>, +) -> Result { + let (pipe_in_name, pipe_out_name) = pipe_pair(); + let h_pipe_in = + create_named_pipe(&pipe_in_name, PIPE_ACCESS_OUTBOUND, &sandbox_creds.username)?; + let h_pipe_out = + create_named_pipe(&pipe_out_name, PIPE_ACCESS_INBOUND, &sandbox_creds.username)?; + + let runner_exe = find_runner_exe(codex_home, log_dir); + let runner_cmdline = runner_exe + .to_str() + .map(str::to_owned) + .unwrap_or_else(|| "codex-command-runner.exe".to_string()); + let runner_full_cmd = format!( + "{} {} {}", + quote_windows_arg(&runner_cmdline), + quote_windows_arg(&format!("--pipe-in={pipe_in_name}")), + quote_windows_arg(&format!("--pipe-out={pipe_out_name}")) + ); + let mut cmdline_vec = to_wide(&runner_full_cmd); + let exe_w = to_wide(&runner_cmdline); + let cwd_w = to_wide(cwd); + let user_w = to_wide(&sandbox_creds.username); + let domain_w = to_wide("."); + let password_w = to_wide(&sandbox_creds.password); + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let env_block: Option> = None; + + let previous_error_mode = unsafe { SetErrorMode(RUNNER_ERROR_MODE_FLAGS) }; + let spawn_res = unsafe { + CreateProcessWithLogonW( + user_w.as_ptr(), + domain_w.as_ptr(), + password_w.as_ptr(), + LOGON_WITH_PROFILE, + exe_w.as_ptr(), + cmdline_vec.as_mut_ptr(), + windows_sys::Win32::System::Threading::CREATE_NO_WINDOW + | windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT, + env_block + .as_ref() + .map(|block| block.as_ptr() as *const c_void) + .unwrap_or(ptr::null()), + cwd_w.as_ptr(), + &si, + &mut pi, + ) + }; + unsafe { + SetErrorMode(previous_error_mode); + } + if spawn_res == 0 { + let err = unsafe { GetLastError() } as i32; + unsafe { + CloseHandle(h_pipe_in); + CloseHandle(h_pipe_out); + } + return Err(anyhow::anyhow!("CreateProcessWithLogonW failed: {err}")); + } + + let connect_result = (|| -> Result<()> { + connect_pipe(h_pipe_in)?; + connect_pipe(h_pipe_out)?; + Ok(()) + })(); + + unsafe { + if pi.hThread != 0 { + CloseHandle(pi.hThread); + } + if pi.hProcess != 0 { + CloseHandle(pi.hProcess); + } + } + + if let Err(err) = connect_result { + unsafe { + CloseHandle(h_pipe_in); + CloseHandle(h_pipe_out); + } + return Err(err); + } + + let pipe_write = unsafe { File::from_raw_handle(h_pipe_in as _) }; + let pipe_read = unsafe { File::from_raw_handle(h_pipe_out as _) }; + Ok(RunnerTransport { + pipe_write, + pipe_read, + }) +} + +fn wait_for_complete_frame(pipe_read: &File, timeout: Duration) -> Result<()> { + let handle = pipe_read.as_raw_handle() as HANDLE; + let deadline = Instant::now() + timeout; + let mut len_buf = [0u8; 4]; + + loop { + let mut bytes_read = 0u32; + let mut total_available = 0u32; + let ok = unsafe { + PeekNamedPipe( + handle, + len_buf.as_mut_ptr() as *mut c_void, + len_buf.len() as u32, + &mut bytes_read, + &mut total_available, + ptr::null_mut(), + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() } as i32; + return Err(anyhow::anyhow!( + "PeekNamedPipe failed while waiting for spawn_ready: {err}" + )); + } + + if bytes_read == len_buf.len() as u32 { + let frame_len = u32::from_le_bytes(len_buf) as usize; + let total_len = frame_len + .checked_add(len_buf.len()) + .ok_or_else(|| anyhow::anyhow!("runner frame length overflow"))?; + if total_available as usize >= total_len { + return Ok(()); + } + } + + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out after {}ms waiting for runner spawn_ready", + timeout.as_millis() + )); + } + + std::thread::sleep(RUNNER_SPAWN_READY_POLL_INTERVAL); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/elevated/runner_pipe.rs b/codex-rs/windows-sandbox-rs/src/elevated/runner_pipe.rs index 0bc74fa863..904c5102a5 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/runner_pipe.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/runner_pipe.rs @@ -20,6 +20,8 @@ use std::path::PathBuf; use std::ptr; use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; @@ -43,7 +45,8 @@ pub fn find_runner_exe(codex_home: &Path, log_dir: Option<&Path>) -> PathBuf { /// Generates a unique named-pipe path used to communicate with the runner process. pub fn pipe_pair() -> (String, String) { let mut rng = SmallRng::from_entropy(); - let base = format!(r"\\.\pipe\codex-runner-{:x}", rng.gen::()); + let nonce: u128 = rng.r#gen(); + let base = format!(r"\\.\pipe\codex-runner-{nonce:x}"); (format!("{base}-in"), format!("{base}-out")) } @@ -86,6 +89,9 @@ pub fn create_named_pipe(name: &str, access: u32, sandbox_username: &str) -> io: &mut sa as *mut SECURITY_ATTRIBUTES, ) }; + unsafe { + LocalFree(sd as HLOCAL); + } if h == 0 || h == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { return Err(io::Error::from_raw_os_error(unsafe { GetLastError() as i32 diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 3b9601b16f..b807ded589 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -35,6 +35,10 @@ windows_modules!( #[path = "conpty/mod.rs"] mod conpty; +#[cfg(target_os = "windows")] +#[path = "proc_thread_attr.rs"] +mod proc_thread_attr; + #[cfg(target_os = "windows")] #[path = "elevated/ipc_framed.rs"] pub(crate) mod ipc_framed; @@ -46,9 +50,29 @@ mod setup; #[cfg(target_os = "windows")] mod elevated_impl; +#[cfg(target_os = "windows")] +#[path = "elevated/runner_pipe.rs"] +mod runner_pipe; + +#[cfg(target_os = "windows")] +#[path = "elevated/runner_client.rs"] +mod runner_client; + #[cfg(target_os = "windows")] mod setup_error; +#[cfg(target_os = "windows")] +#[path = "sandbox_utils.rs"] +mod sandbox_utils; + +#[cfg(target_os = "windows")] +#[path = "spawn_prep.rs"] +mod spawn_prep; + +#[cfg(target_os = "windows")] +#[path = "unified_exec/session.rs"] +mod session; + #[cfg(target_os = "windows")] pub use acl::add_deny_write_ace; @@ -73,6 +97,8 @@ pub use cap::workspace_cap_sid_for_cwd; #[cfg(target_os = "windows")] pub use conpty::spawn_conpty_process_as_user; #[cfg(target_os = "windows")] +pub use desktop::LaunchDesktop; +#[cfg(target_os = "windows")] pub use dpapi::protect as dpapi_protect; #[cfg(target_os = "windows")] pub use dpapi::unprotect as dpapi_unprotect; @@ -103,6 +129,8 @@ pub use ipc_framed::OutputPayload; #[cfg(target_os = "windows")] pub use ipc_framed::OutputStream; #[cfg(target_os = "windows")] +pub use ipc_framed::ResizePayload; +#[cfg(target_os = "windows")] pub use ipc_framed::SpawnReady; #[cfg(target_os = "windows")] pub use ipc_framed::SpawnRequest; @@ -137,6 +165,10 @@ pub use process::read_handle_loop; #[cfg(target_os = "windows")] pub use process::spawn_process_with_pipes; #[cfg(target_os = "windows")] +pub use session::spawn_windows_sandbox_session_elevated; +#[cfg(target_os = "windows")] +pub use session::spawn_windows_sandbox_session_legacy; +#[cfg(target_os = "windows")] pub use setup::SETUP_VERSION; #[cfg(target_os = "windows")] pub use setup::SandboxSetupRequest; @@ -214,16 +246,13 @@ mod windows_impl { use super::allow::compute_allow_paths; use super::cap::load_or_create_cap_sids; use super::cap::workspace_cap_sid_for_cwd; - use super::env::apply_no_network_to_env; - use super::env::ensure_non_interactive_pager; - use super::env::normalize_null_device_env; use super::logging::log_failure; - use super::logging::log_start; use super::logging::log_success; use super::path_normalization::canonicalize_path; use super::policy::SandboxPolicy; - use super::policy::parse_policy; use super::process::create_process_as_user; + use super::sandbox_utils::ensure_codex_home_exists; + use super::spawn_prep::prepare_legacy_spawn_context; use super::token::convert_string_sid_to_sid; use super::token::create_workspace_write_token_with_caps_from; use super::workspace_acl::is_command_cwd_root; @@ -246,15 +275,6 @@ mod windows_impl { type PipeHandles = ((HANDLE, HANDLE), (HANDLE, HANDLE), (HANDLE, HANDLE)); - fn should_apply_network_block(policy: &SandboxPolicy) -> bool { - !policy.has_full_network_access() - } - - fn ensure_codex_home_exists(p: &Path) -> Result<()> { - std::fs::create_dir_all(p)?; - Ok(()) - } - unsafe fn setup_stdio_pipes() -> io::Result { let mut in_r: HANDLE = 0; let mut in_w: HANDLE = 0; @@ -326,27 +346,19 @@ mod windows_impl { additional_deny_write_paths: &[PathBuf], use_private_desktop: bool, ) -> Result { - let policy = parse_policy(policy_json_or_preset)?; - let apply_network_block = should_apply_network_block(&policy); - normalize_null_device_env(&mut env_map); - ensure_non_interactive_pager(&mut env_map); - if apply_network_block { - apply_no_network_to_env(&mut env_map)?; - } - ensure_codex_home_exists(codex_home)?; - let current_dir = cwd.to_path_buf(); - let sandbox_base = codex_home.join(".sandbox"); - std::fs::create_dir_all(&sandbox_base)?; - let logs_base_dir = Some(sandbox_base.as_path()); - log_start(&command, logs_base_dir); - let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); - - if matches!( - &policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) { - anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") - } + let common = prepare_legacy_spawn_context( + policy_json_or_preset, + codex_home, + cwd, + &mut env_map, + &command, + /*inherit_path*/ false, + /*add_git_safe_directory*/ false, + )?; + let policy = common.policy; + let current_dir = common.current_dir; + let logs_base_dir = common.logs_base_dir.as_deref(); + let is_workspace_write = common.is_workspace_write; if !policy.has_full_disk_read_access() { anyhow::bail!( "Restricted read-only access requires the elevated Windows sandbox backend" @@ -440,7 +452,6 @@ mod windows_impl { allow_null_device(psid); } } - let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); let spawn_res = unsafe { @@ -571,7 +582,6 @@ mod windows_impl { } } } - Ok(CaptureResult { exit_code, stdout, @@ -605,7 +615,6 @@ mod windows_impl { let AllowDenyPaths { allow, deny } = compute_allow_paths(sandbox_policy, sandbox_policy_cwd, ¤t_dir, env_map); let canonical_cwd = canonicalize_path(¤t_dir); - unsafe { for p in &allow { let psid = if is_command_cwd_root(p, &canonical_cwd) { @@ -627,8 +636,8 @@ mod windows_impl { #[cfg(test)] mod tests { - use super::should_apply_network_block; use crate::policy::SandboxPolicy; + use crate::spawn_prep::should_apply_network_block; fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { diff --git a/codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs b/codex-rs/windows-sandbox-rs/src/proc_thread_attr.rs similarity index 65% rename from codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs rename to codex-rs/windows-sandbox-rs/src/proc_thread_attr.rs index 393172a0f8..7641bb5edd 100644 --- a/codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs +++ b/codex-rs/windows-sandbox-rs/src/proc_thread_attr.rs @@ -1,25 +1,20 @@ -//! Low-level Windows thread attribute helpers used by ConPTY spawn. -//! -//! This module wraps the Win32 `PROC_THREAD_ATTRIBUTE_LIST` APIs so ConPTY handles can -//! be attached to a child process. It is ConPTY‑specific and used in both legacy and -//! elevated unified_exec paths when spawning a PTY‑backed process. - use std::io; use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::System::Threading::DeleteProcThreadAttributeList; use windows_sys::Win32::System::Threading::InitializeProcThreadAttributeList; use windows_sys::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST; use windows_sys::Win32::System::Threading::UpdateProcThreadAttribute; -const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016; +const PROC_THREAD_ATTRIBUTE_HANDLE_LIST: usize = 0x0002_0002; +const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x0002_0016; -/// RAII wrapper for Windows PROC_THREAD_ATTRIBUTE_LIST. pub struct ProcThreadAttributeList { buffer: Vec, + handle_list: Option>, } impl ProcThreadAttributeList { - /// Allocate and initialize a thread attribute list. pub fn new(attr_count: u32) -> io::Result { let mut size: usize = 0; unsafe { @@ -38,15 +33,16 @@ impl ProcThreadAttributeList { GetLastError() as i32 })); } - Ok(Self { buffer }) + Ok(Self { + buffer, + handle_list: None, + }) } - /// Return a mutable pointer to the attribute list for Win32 APIs. pub fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { self.buffer.as_mut_ptr() as LPPROC_THREAD_ATTRIBUTE_LIST } - /// Attach a ConPTY handle to the attribute list. pub fn set_pseudoconsole(&mut self, hpc: isize) -> io::Result<()> { let list = self.as_mut_ptr(); let mut hpc_value = hpc; @@ -68,6 +64,31 @@ impl ProcThreadAttributeList { } Ok(()) } + + pub fn set_handle_list(&mut self, handles: Vec) -> io::Result<()> { + self.handle_list = Some(handles); + let list = self.as_mut_ptr(); + let Some(handle_list) = self.handle_list.as_mut() else { + return Err(io::Error::other("handle list missing after initialization")); + }; + let ok = unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handle_list.as_mut_ptr().cast(), + std::mem::size_of_val(handle_list.as_slice()), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::from_raw_os_error(unsafe { + GetLastError() as i32 + })); + } + Ok(()) + } } impl Drop for ProcThreadAttributeList { diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index ec31b06b5f..1571bb8ccf 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -1,7 +1,8 @@ use crate::desktop::LaunchDesktop; use crate::logging; +use crate::proc_thread_attr::ProcThreadAttributeList; +use crate::winutil::argv_to_command_line; use crate::winutil::format_last_error; -use crate::winutil::quote_windows_arg; use crate::winutil::to_wide; use anyhow::anyhow; use anyhow::Result; @@ -23,8 +24,10 @@ use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::CreateProcessAsUserW; use windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT; +use windows_sys::Win32::System::Threading::EXTENDED_STARTUPINFO_PRESENT; use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; use windows_sys::Win32::System::Threading::STARTF_USESTDHANDLES; +use windows_sys::Win32::System::Threading::STARTUPINFOEXW; use windows_sys::Win32::System::Threading::STARTUPINFOW; pub struct CreatedProcess { @@ -81,81 +84,118 @@ pub unsafe fn create_process_as_user( stdio: Option<(HANDLE, HANDLE, HANDLE)>, use_private_desktop: bool, ) -> Result { - let cmdline_str = argv - .iter() - .map(|a| quote_windows_arg(a)) - .collect::>() - .join(" "); + let cmdline_str = argv_to_command_line(argv); let mut cmdline: Vec = to_wide(&cmdline_str); let env_block = make_env_block(env_map); - let mut si: STARTUPINFOW = std::mem::zeroed(); - si.cb = std::mem::size_of::() as u32; - // Some processes (e.g., PowerShell) can fail with STATUS_DLL_INIT_FAILED - // if lpDesktop is not set when launching with a restricted token. - // Point explicitly at the interactive desktop or a private desktop. let desktop = LaunchDesktop::prepare(use_private_desktop, logs_base_dir)?; - si.lpDesktop = desktop.startup_info_desktop(); let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); - // Ensure handles are inheritable when custom stdio is supplied. - let inherit_handles = match stdio { + let cwd_wide = to_wide(cwd); + let env_block_len = env_block.len(); + match stdio { Some((stdin_h, stdout_h, stderr_h)) => { - si.dwFlags |= STARTF_USESTDHANDLES; - si.hStdInput = stdin_h; - si.hStdOutput = stdout_h; - si.hStdError = stderr_h; - for h in [stdin_h, stdout_h, stderr_h] { - if SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + let mut si: STARTUPINFOEXW = std::mem::zeroed(); + si.StartupInfo.cb = std::mem::size_of::() as u32; + // Some processes (e.g., PowerShell) can fail with STATUS_DLL_INIT_FAILED + // if lpDesktop is not set when launching with a restricted token. + // Point explicitly at the interactive desktop or a private desktop. + si.StartupInfo.lpDesktop = desktop.startup_info_desktop(); + si.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = stdin_h; + si.StartupInfo.hStdOutput = stdout_h; + si.StartupInfo.hStdError = stderr_h; + let mut inherited_handles = vec![stdin_h, stdout_h]; + if !inherited_handles.contains(&stderr_h) { + inherited_handles.push(stderr_h); + } + for &handle in &inherited_handles { + if SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { return Err(anyhow!( "SetHandleInformation failed for stdio handle: {}", GetLastError() )); } } - true + let mut attrs = ProcThreadAttributeList::new(/*attr_count*/ 1)?; + attrs.set_handle_list(inherited_handles)?; + si.lpAttributeList = attrs.as_mut_ptr(); + + let creation_flags = CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT; + let ok = CreateProcessAsUserW( + h_token, + std::ptr::null(), + cmdline.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + 1, + creation_flags, + env_block.as_ptr() as *mut c_void, + cwd_wide.as_ptr(), + &si.StartupInfo, + &mut pi, + ); + if ok == 0 { + let err = GetLastError() as i32; + let msg = format!( + "CreateProcessAsUserW failed: {} ({}) | cwd={} | cmd={} | env_u16_len={} | si_flags={} | creation_flags={}", + err, + format_last_error(err), + cwd.display(), + cmdline_str, + env_block_len, + si.StartupInfo.dwFlags, + creation_flags, + ); + logging::debug_log(&msg, logs_base_dir); + return Err(anyhow!("CreateProcessAsUserW failed: {err}")); + } + Ok(CreatedProcess { + process_info: pi, + startup_info: si.StartupInfo, + _desktop: desktop, + }) } None => { + let mut si: STARTUPINFOW = std::mem::zeroed(); + si.cb = std::mem::size_of::() as u32; + si.lpDesktop = desktop.startup_info_desktop(); ensure_inheritable_stdio(&mut si)?; - true + + let creation_flags = CREATE_UNICODE_ENVIRONMENT; + let ok = CreateProcessAsUserW( + h_token, + std::ptr::null(), + cmdline.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + 1, + creation_flags, + env_block.as_ptr() as *mut c_void, + cwd_wide.as_ptr(), + &si, + &mut pi, + ); + if ok == 0 { + let err = GetLastError() as i32; + let msg = format!( + "CreateProcessAsUserW failed: {} ({}) | cwd={} | cmd={} | env_u16_len={} | si_flags={} | creation_flags={}", + err, + format_last_error(err), + cwd.display(), + cmdline_str, + env_block_len, + si.dwFlags, + creation_flags, + ); + logging::debug_log(&msg, logs_base_dir); + return Err(anyhow!("CreateProcessAsUserW failed: {err}")); + } + Ok(CreatedProcess { + process_info: pi, + startup_info: si, + _desktop: desktop, + }) } - }; - - let creation_flags = CREATE_UNICODE_ENVIRONMENT; - let cwd_wide = to_wide(cwd); - let env_block_len = env_block.len(); - - let ok = CreateProcessAsUserW( - h_token, - std::ptr::null(), - cmdline.as_mut_ptr(), - std::ptr::null_mut(), - std::ptr::null_mut(), - inherit_handles as i32, - creation_flags, - env_block.as_ptr() as *mut c_void, - cwd_wide.as_ptr(), - &si, - &mut pi, - ); - if ok == 0 { - let err = GetLastError() as i32; - let msg = format!( - "CreateProcessAsUserW failed: {} ({}) | cwd={} | cmd={} | env_u16_len={} | si_flags={} | creation_flags={}", - err, - format_last_error(err), - cwd.display(), - cmdline_str, - env_block_len, - si.dwFlags, - creation_flags, - ); - logging::debug_log(&msg, logs_base_dir); - return Err(anyhow!("CreateProcessAsUserW failed: {err}")); } - Ok(CreatedProcess { - process_info: pi, - startup_info: si, - _desktop: desktop, - }) } /// Controls whether the child's stdin handle is kept open for writing. @@ -179,9 +219,11 @@ pub struct PipeSpawnHandles { pub stdin_write: Option, pub stdout_read: HANDLE, pub stderr_read: Option, + pub(crate) desktop: LaunchDesktop, } /// Spawns a process with anonymous pipes and returns the relevant handles. +#[allow(clippy::too_many_arguments)] pub fn spawn_process_with_pipes( h_token: HANDLE, argv: &[String], @@ -190,6 +232,7 @@ pub fn spawn_process_with_pipes( stdin_mode: StdinMode, stderr_mode: StderrMode, use_private_desktop: bool, + logs_base_dir: Option<&Path>, ) -> Result { let mut in_r: HANDLE = 0; let mut in_w: HANDLE = 0; @@ -229,7 +272,7 @@ pub fn spawn_process_with_pipes( argv, cwd, env_map, - /*logs_base_dir*/ None, + logs_base_dir, stdio, use_private_desktop, ) @@ -250,7 +293,11 @@ pub fn spawn_process_with_pipes( return Err(err); } }; - let pi = created.process_info; + let CreatedProcess { + process_info: pi, + _desktop: desktop, + .. + } = created; unsafe { CloseHandle(in_r); @@ -274,6 +321,7 @@ pub fn spawn_process_with_pipes( StderrMode::Separate => Some(err_r), StderrMode::MergeStdout => None, }, + desktop, }) } diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs b/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs index ed2aa509cf..5d64e5f844 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_utils.rs @@ -19,16 +19,16 @@ fn find_git_root(start: &Path) -> Option { return Some(cur); } if marker.is_file() { - if let Ok(txt) = std::fs::read_to_string(&marker) { - if let Some(rest) = txt.trim().strip_prefix("gitdir:") { - let gitdir = rest.trim(); - let resolved = if Path::new(gitdir).is_absolute() { - PathBuf::from(gitdir) - } else { - cur.join(gitdir) - }; - return resolved.parent().map(|p| p.to_path_buf()).or(Some(cur)); - } + if let Ok(txt) = std::fs::read_to_string(&marker) + && let Some(rest) = txt.trim().strip_prefix("gitdir:") + { + let gitdir = rest.trim(); + let resolved = if Path::new(gitdir).is_absolute() { + PathBuf::from(gitdir) + } else { + cur.join(gitdir) + }; + return resolved.parent().map(Path::to_path_buf).or(Some(cur)); } return Some(cur); } diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs new file mode 100644 index 0000000000..caf19f332b --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -0,0 +1,306 @@ +use crate::acl::add_allow_ace; +use crate::acl::add_deny_write_ace; +use crate::acl::allow_null_device; +use crate::allow::AllowDenyPaths; +use crate::allow::compute_allow_paths; +use crate::cap::load_or_create_cap_sids; +use crate::cap::workspace_cap_sid_for_cwd; +use crate::env::apply_no_network_to_env; +use crate::env::ensure_non_interactive_pager; +use crate::env::inherit_path_env; +use crate::env::normalize_null_device_env; +use crate::identity::SandboxCreds; +use crate::identity::require_logon_sandbox_creds; +use crate::logging::log_start; +use crate::path_normalization::canonicalize_path; +use crate::policy::SandboxPolicy; +use crate::policy::parse_policy; +use crate::sandbox_utils::ensure_codex_home_exists; +use crate::sandbox_utils::inject_git_safe_directory; +use crate::token::convert_string_sid_to_sid; +use crate::token::create_readonly_token_with_cap; +use crate::token::create_workspace_write_token_with_caps_from; +use crate::token::get_current_token_for_restriction; +use crate::token::get_logon_sid_bytes; +use crate::workspace_acl::is_command_cwd_root; +use crate::workspace_acl::protect_workspace_agents_dir; +use crate::workspace_acl::protect_workspace_codex_dir; +use anyhow::Result; +use std::collections::HashMap; +use std::ffi::c_void; +use std::path::Path; +use std::path::PathBuf; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; + +pub(crate) struct SpawnContext { + pub(crate) policy: SandboxPolicy, + pub(crate) current_dir: PathBuf, + pub(crate) sandbox_base: PathBuf, + pub(crate) logs_base_dir: Option, + pub(crate) is_workspace_write: bool, +} + +pub(crate) struct ElevatedSpawnContext { + pub(crate) common: SpawnContext, + pub(crate) sandbox_creds: SandboxCreds, + pub(crate) cap_sids: Vec, +} + +pub(crate) struct LegacySessionSecurity { + pub(crate) h_token: HANDLE, + pub(crate) psid_generic: LocalSid, + pub(crate) psid_workspace: Option, + pub(crate) cap_sid_str: String, +} + +pub(crate) struct LocalSid { + psid: *mut c_void, +} + +impl LocalSid { + pub(crate) fn from_string(sid: &str) -> Result { + let psid = unsafe { convert_string_sid_to_sid(sid) } + .ok_or_else(|| anyhow::anyhow!("invalid SID string: {sid}"))?; + Ok(Self { psid }) + } + + pub(crate) fn as_ptr(&self) -> *mut c_void { + self.psid + } +} + +impl Drop for LocalSid { + fn drop(&mut self) { + if !self.psid.is_null() { + unsafe { + LocalFree(self.psid as HLOCAL); + } + } + } +} + +pub(crate) fn should_apply_network_block(policy: &SandboxPolicy) -> bool { + !policy.has_full_network_access() +} + +pub(crate) fn prepare_legacy_spawn_context( + policy_json_or_preset: &str, + codex_home: &Path, + cwd: &Path, + env_map: &mut HashMap, + command: &[String], + inherit_path: bool, + add_git_safe_directory: bool, +) -> Result { + let policy = parse_policy(policy_json_or_preset)?; + if matches!( + &policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { + anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") + } + + normalize_null_device_env(env_map); + ensure_non_interactive_pager(env_map); + if inherit_path { + inherit_path_env(env_map); + } + if add_git_safe_directory { + inject_git_safe_directory(env_map, cwd); + } + if should_apply_network_block(&policy) { + apply_no_network_to_env(env_map)?; + } + + ensure_codex_home_exists(codex_home)?; + let sandbox_base = codex_home.join(".sandbox"); + std::fs::create_dir_all(&sandbox_base)?; + let logs_base_dir = Some(sandbox_base.clone()); + log_start(command, logs_base_dir.as_deref()); + + let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); + + Ok(SpawnContext { + policy, + current_dir: cwd.to_path_buf(), + sandbox_base, + logs_base_dir, + is_workspace_write, + }) +} + +pub(crate) fn prepare_legacy_session_security( + policy: &SandboxPolicy, + codex_home: &Path, + cwd: &Path, +) -> Result { + let caps = load_or_create_cap_sids(codex_home)?; + let (h_token, psid_generic, psid_workspace, cap_sid_str) = unsafe { + match policy { + SandboxPolicy::ReadOnly { .. } => { + let psid = LocalSid::from_string(&caps.readonly)?; + let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?; + (h_token, psid, None, caps.readonly) + } + SandboxPolicy::WorkspaceWrite { .. } => { + let psid_generic = LocalSid::from_string(&caps.workspace)?; + let workspace_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; + let psid_workspace = LocalSid::from_string(&workspace_sid)?; + let base = get_current_token_for_restriction()?; + let h_token = create_workspace_write_token_with_caps_from( + base, + &[psid_generic.as_ptr(), psid_workspace.as_ptr()], + ); + CloseHandle(base); + let h_token = h_token?; + (h_token, psid_generic, Some(psid_workspace), caps.workspace) + } + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + unreachable!("dangerous policies rejected before legacy session prep") + } + } + }; + + Ok(LegacySessionSecurity { + h_token, + psid_generic, + psid_workspace, + cap_sid_str, + }) +} + +pub(crate) fn allow_null_device_for_workspace_write(is_workspace_write: bool) { + if !is_workspace_write { + return; + } + + unsafe { + if let Ok(base) = get_current_token_for_restriction() { + if let Ok(bytes) = get_logon_sid_bytes(base) { + let mut tmp = bytes; + let psid = tmp.as_mut_ptr() as *mut c_void; + allow_null_device(psid); + } + CloseHandle(base); + } + } +} + +pub(crate) fn apply_legacy_session_acl_rules( + policy: &SandboxPolicy, + sandbox_policy_cwd: &Path, + current_dir: &Path, + env_map: &HashMap, + psid_generic: &LocalSid, + psid_workspace: Option<&LocalSid>, + persist_aces: bool, +) -> Vec { + let AllowDenyPaths { allow, deny } = + compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map); + let mut guards: Vec = Vec::new(); + let canonical_cwd = canonicalize_path(current_dir); + unsafe { + for p in &allow { + let psid = if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) + && is_command_cwd_root(p, &canonical_cwd) + { + psid_workspace.unwrap_or(psid_generic).as_ptr() + } else { + psid_generic.as_ptr() + }; + if matches!(add_allow_ace(p, psid), Ok(true)) && !persist_aces { + guards.push(p.clone()); + } + } + for p in &deny { + if let Ok(added) = add_deny_write_ace(p, psid_generic.as_ptr()) + && added + && !persist_aces + { + guards.push(p.clone()); + } + } + allow_null_device(psid_generic.as_ptr()); + if let Some(psid_workspace) = psid_workspace { + allow_null_device(psid_workspace.as_ptr()); + if persist_aces && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { + let _ = protect_workspace_codex_dir(current_dir, psid_workspace.as_ptr()); + let _ = protect_workspace_agents_dir(current_dir, psid_workspace.as_ptr()); + } + } + } + guards +} + +pub(crate) fn prepare_elevated_spawn_context( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + cwd: &Path, + env_map: &mut HashMap, + command: &[String], +) -> Result { + let common = prepare_legacy_spawn_context( + policy_json_or_preset, + codex_home, + cwd, + env_map, + command, + /*inherit_path*/ true, + /*add_git_safe_directory*/ true, + )?; + let AllowDenyPaths { allow, deny } = compute_allow_paths( + &common.policy, + sandbox_policy_cwd, + &common.current_dir, + env_map, + ); + let write_roots: Vec = allow.into_iter().collect(); + let deny_write_paths: Vec = deny.into_iter().collect(); + let write_roots_override = if common.is_workspace_write { + Some(write_roots.as_slice()) + } else { + None + }; + let sandbox_creds = require_logon_sandbox_creds( + &common.policy, + sandbox_policy_cwd, + cwd, + env_map, + codex_home, + /*read_roots_override*/ None, + write_roots_override, + &deny_write_paths, + /*proxy_enforced*/ false, + )?; + let caps = load_or_create_cap_sids(codex_home)?; + let (psid_to_use, cap_sids) = match &common.policy { + SandboxPolicy::ReadOnly { .. } => ( + LocalSid::from_string(&caps.readonly)?, + vec![caps.readonly.clone()], + ), + SandboxPolicy::WorkspaceWrite { .. } => { + let cap_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; + ( + LocalSid::from_string(&caps.workspace)?, + vec![caps.workspace.clone(), cap_sid], + ) + } + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + unreachable!("dangerous policies rejected before elevated session prep") + } + }; + + unsafe { + allow_null_device(psid_to_use.as_ptr()); + } + + Ok(ElevatedSpawnContext { + common, + sandbox_creds, + cap_sids, + }) +} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs new file mode 100644 index 0000000000..0ed408fbfe --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs @@ -0,0 +1,118 @@ +use super::windows_common::finish_driver_spawn; +use super::windows_common::make_runner_resizer; +use super::windows_common::start_runner_pipe_writer; +use super::windows_common::start_runner_stdin_writer; +use super::windows_common::start_runner_stdout_reader; +use crate::ipc_framed::EmptyPayload; +use crate::ipc_framed::FramedMessage; +use crate::ipc_framed::Message; +use crate::ipc_framed::SpawnRequest; +use crate::runner_client::spawn_runner_transport; +use crate::spawn_prep::prepare_elevated_spawn_context; +use anyhow::Result; +use codex_utils_pty::ProcessDriver; +use codex_utils_pty::SpawnedProcess; +use std::collections::HashMap; +use std::path::Path; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn spawn_windows_sandbox_session_elevated( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + mut env_map: HashMap, + timeout_ms: Option, + tty: bool, + stdin_open: bool, + use_private_desktop: bool, +) -> Result { + let elevated = prepare_elevated_spawn_context( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + cwd, + &mut env_map, + &command, + )?; + + let spawn_request = SpawnRequest { + command: command.clone(), + cwd: cwd.to_path_buf(), + env: env_map.clone(), + policy_json_or_preset: policy_json_or_preset.to_string(), + sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(), + codex_home: elevated.common.sandbox_base.clone(), + real_codex_home: codex_home.to_path_buf(), + cap_sids: elevated.cap_sids.clone(), + timeout_ms, + tty, + stdin_open, + use_private_desktop, + }; + let codex_home = codex_home.to_path_buf(); + let cwd = cwd.to_path_buf(); + let sandbox_creds = elevated.sandbox_creds.clone(); + let logs_base_dir = elevated.common.logs_base_dir.clone(); + let transport = tokio::task::spawn_blocking(move || -> Result<_> { + let mut transport = + spawn_runner_transport(&codex_home, &cwd, &sandbox_creds, logs_base_dir.as_deref())?; + transport.send_spawn_request(spawn_request)?; + transport.read_spawn_ready()?; + Ok(transport) + }) + .await + .map_err(|err| anyhow::anyhow!("runner handshake task failed: {err}"))??; + let (pipe_write, pipe_read) = transport.into_files(); + + let (writer_tx, writer_rx) = mpsc::channel::>(128); + let (stdout_tx, stdout_rx) = broadcast::channel::>(256); + let stderr_rx = if tty { + None + } else { + Some(broadcast::channel::>(256)) + }; + let (exit_tx, exit_rx) = oneshot::channel::(); + + let outbound_tx = start_runner_pipe_writer(pipe_write); + let writer_handle = start_runner_stdin_writer(writer_rx, outbound_tx.clone(), tty, stdin_open); + let terminator = { + let outbound_tx = outbound_tx.clone(); + Some(Box::new(move || { + let _ = outbound_tx.send(FramedMessage { + version: 1, + message: Message::Terminate { + payload: EmptyPayload::default(), + }, + }); + }) as Box) + }; + + start_runner_stdout_reader( + pipe_read, + stdout_tx, + stderr_rx.as_ref().map(|(tx, _rx)| tx.clone()), + exit_tx, + ); + + Ok(finish_driver_spawn( + ProcessDriver { + writer_tx, + stdout_rx, + stderr_rx: stderr_rx.map(|(_tx, rx)| rx), + exit_rx, + terminator, + writer_handle: Some(writer_handle), + resizer: if tty { + Some(make_runner_resizer(outbound_tx)) + } else { + None + }, + }, + stdin_open, + )) +} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs new file mode 100644 index 0000000000..ba1f15a3be --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -0,0 +1,439 @@ +use super::windows_common::finish_driver_spawn; +use super::windows_common::normalize_windows_tty_input; +use crate::acl::revoke_ace; +use crate::conpty::spawn_conpty_process_as_user; +use crate::desktop::LaunchDesktop; +use crate::logging::log_failure; +use crate::logging::log_success; +use crate::process::StderrMode; +use crate::process::StdinMode; +use crate::process::read_handle_loop; +use crate::process::spawn_process_with_pipes; +use crate::spawn_prep::LocalSid; +use crate::spawn_prep::allow_null_device_for_workspace_write; +use crate::spawn_prep::apply_legacy_session_acl_rules; +use crate::spawn_prep::prepare_legacy_session_security; +use crate::spawn_prep::prepare_legacy_spawn_context; +use anyhow::Result; +use codex_utils_pty::ProcessDriver; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::ptr; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Storage::FileSystem::WriteFile; +use windows_sys::Win32::System::Console::COORD; +use windows_sys::Win32::System::Console::ClosePseudoConsole; +use windows_sys::Win32::System::Console::ResizePseudoConsole; +use windows_sys::Win32::System::Threading::GetExitCodeProcess; +use windows_sys::Win32::System::Threading::INFINITE; +use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; +use windows_sys::Win32::System::Threading::TerminateProcess; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +const WAIT_TIMEOUT: u32 = 0x0000_0102; + +struct LegacyProcessHandles { + process: PROCESS_INFORMATION, + output_join: std::thread::JoinHandle<()>, + writer_handle: tokio::task::JoinHandle<()>, + hpc: Option, + token_handle: HANDLE, + desktop: Option, +} + +#[allow(clippy::too_many_arguments)] +fn spawn_legacy_process( + h_token: HANDLE, + command: &[String], + cwd: &Path, + env_map: &HashMap, + use_private_desktop: bool, + tty: bool, + stdin_open: bool, + stdout_tx: broadcast::Sender>, + stderr_tx: Option>>, + writer_rx: mpsc::Receiver>, + logs_base_dir: Option<&Path>, +) -> Result { + let (pi, output_join, writer_handle, hpc, desktop) = if tty { + let (pi, conpty) = spawn_conpty_process_as_user( + h_token, + command, + cwd, + env_map, + use_private_desktop, + logs_base_dir, + )?; + let (hpc, input_write, output_read, desktop) = conpty.into_raw(); + let output_join = spawn_output_reader(output_read, stdout_tx); + let writer_handle = spawn_input_writer( + Some(input_write), + writer_rx, + /*normalize_newlines*/ true, + ); + (pi, output_join, writer_handle, Some(hpc), desktop) + } else { + let pipe_handles = spawn_process_with_pipes( + h_token, + command, + cwd, + env_map, + if stdin_open { + StdinMode::Open + } else { + StdinMode::Closed + }, + StderrMode::Separate, + use_private_desktop, + logs_base_dir, + )?; + let stdout_join = spawn_output_reader(pipe_handles.stdout_read, stdout_tx); + let Some(stderr_read) = pipe_handles.stderr_read else { + anyhow::bail!("separate stderr handle should be present"); + }; + let Some(stderr_tx) = stderr_tx else { + anyhow::bail!("separate stderr channel should be present"); + }; + let stderr_join = spawn_output_reader(stderr_read, stderr_tx); + let output_join = std::thread::spawn(move || { + let _ = stdout_join.join(); + let _ = stderr_join.join(); + }); + let writer_handle = spawn_input_writer( + pipe_handles.stdin_write, + writer_rx, + /*normalize_newlines*/ false, + ); + ( + pipe_handles.process, + output_join, + writer_handle, + None, + Some(pipe_handles.desktop), + ) + }; + Ok(LegacyProcessHandles { + process: pi, + output_join, + writer_handle, + hpc, + token_handle: h_token, + desktop, + }) +} + +fn spawn_output_reader( + output_read: HANDLE, + output_tx: broadcast::Sender>, +) -> std::thread::JoinHandle<()> { + read_handle_loop(output_read, move |chunk| { + let _ = output_tx.send(chunk.to_vec()); + }) +} + +fn spawn_input_writer( + input_write: Option, + mut writer_rx: mpsc::Receiver>, + normalize_newlines: bool, +) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_blocking(move || { + let mut previous_was_cr = false; + while let Some(bytes) = writer_rx.blocking_recv() { + let Some(handle) = input_write else { + continue; + }; + let bytes = if normalize_newlines { + normalize_windows_tty_input(&bytes, &mut previous_was_cr) + } else { + bytes + }; + if write_all_handle(handle, &bytes).is_err() { + break; + } + } + if let Some(handle) = input_write { + unsafe { + CloseHandle(handle); + } + } + }) +} + +fn write_all_handle(handle: HANDLE, mut bytes: &[u8]) -> Result<()> { + while !bytes.is_empty() { + let mut written = 0u32; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr() as *const _, + bytes.len() as u32, + &mut written, + ptr::null_mut(), + ) + }; + if ok == 0 { + let err = unsafe { GetLastError() } as i32; + return Err(anyhow::anyhow!("WriteFile failed: {err}")); + } + if written == 0 { + anyhow::bail!("WriteFile returned success but wrote 0 bytes"); + } + bytes = &bytes[written as usize..]; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn finalize_exit( + exit_tx: oneshot::Sender, + process_handle: Arc>>, + thread_handle: HANDLE, + output_join: std::thread::JoinHandle<()>, + guards: Vec, + cap_sid: Option, + logs_base_dir: Option<&Path>, + command: Vec, +) { + let exit_code = { + let mut raw_exit = 1u32; + if let Ok(guard) = process_handle.lock() + && let Some(handle) = guard.as_ref() + { + unsafe { + WaitForSingleObject(*handle, INFINITE); + GetExitCodeProcess(*handle, &mut raw_exit); + } + } + raw_exit as i32 + }; + + let _ = output_join.join(); + let _ = exit_tx.send(exit_code); + + unsafe { + if thread_handle != 0 && thread_handle != INVALID_HANDLE_VALUE { + CloseHandle(thread_handle); + } + if let Ok(mut guard) = process_handle.lock() + && let Some(handle) = guard.take() + { + CloseHandle(handle); + } + } + + if exit_code == 0 { + log_success(&command, logs_base_dir); + } else { + log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); + } + + if let Some(cap_sid) = cap_sid + && let Ok(sid) = LocalSid::from_string(&cap_sid) + { + unsafe { + for path in guards { + revoke_ace(&path, sid.as_ptr()); + } + } + } +} + +fn resize_conpty_handle(hpc: &Arc>>, size: TerminalSize) -> Result<()> { + let guard = hpc + .lock() + .map_err(|_| anyhow::anyhow!("failed to lock ConPTY handle"))?; + let hpc = guard + .as_ref() + .copied() + .ok_or_else(|| anyhow::anyhow!("process is not attached to a PTY"))?; + let result = unsafe { + ResizePseudoConsole( + hpc, + COORD { + X: size.cols as i16, + Y: size.rows as i16, + }, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(anyhow::anyhow!( + "failed to resize console: HRESULT {result}" + )) + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn spawn_windows_sandbox_session_legacy( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + mut env_map: HashMap, + timeout_ms: Option, + tty: bool, + stdin_open: bool, + use_private_desktop: bool, +) -> Result { + let common = prepare_legacy_spawn_context( + policy_json_or_preset, + codex_home, + cwd, + &mut env_map, + &command, + /*inherit_path*/ false, + /*add_git_safe_directory*/ false, + )?; + if !common.policy.has_full_disk_read_access() { + anyhow::bail!("Restricted read-only access requires the elevated Windows sandbox backend"); + } + let security = prepare_legacy_session_security(&common.policy, codex_home, cwd)?; + allow_null_device_for_workspace_write(common.is_workspace_write); + + let persist_aces = common.is_workspace_write; + let guards = apply_legacy_session_acl_rules( + &common.policy, + sandbox_policy_cwd, + &common.current_dir, + &env_map, + &security.psid_generic, + security.psid_workspace.as_ref(), + persist_aces, + ); + + let (writer_tx, writer_rx) = mpsc::channel::>(128); + let (stdout_tx, stdout_rx) = broadcast::channel::>(256); + let stderr_rx = if tty { + None + } else { + Some(broadcast::channel::>(256)) + }; + let (exit_tx, exit_rx) = oneshot::channel::(); + + let LegacyProcessHandles { + process: pi, + output_join, + writer_handle, + hpc, + token_handle, + desktop, + } = match spawn_legacy_process( + security.h_token, + &command, + cwd, + &env_map, + use_private_desktop, + tty, + stdin_open, + stdout_tx, + stderr_rx.as_ref().map(|(tx, _rx)| tx.clone()), + writer_rx, + common.logs_base_dir.as_deref(), + ) { + Ok(handles) => handles, + Err(err) => { + unsafe { + if !persist_aces + && !guards.is_empty() + && let Ok(sid) = LocalSid::from_string(&security.cap_sid_str) + { + for path in &guards { + revoke_ace(path, sid.as_ptr()); + } + } + CloseHandle(security.h_token); + } + return Err(err); + } + }; + let hpc_handle = hpc.map(|hpc| Arc::new(StdMutex::new(Some(hpc)))); + + let process_handle = Arc::new(StdMutex::new(Some(pi.hProcess))); + let wait_handle = Arc::clone(&process_handle); + let command_for_wait = command.clone(); + let guards_for_wait = if persist_aces { Vec::new() } else { guards }; + let cap_sid_for_wait = if guards_for_wait.is_empty() { + None + } else { + Some(security.cap_sid_str) + }; + let hpc_for_wait = hpc_handle.clone(); + std::thread::spawn(move || { + let _desktop = desktop; + let timeout = timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE); + let wait_res = unsafe { WaitForSingleObject(pi.hProcess, timeout) }; + if wait_res == WAIT_TIMEOUT { + unsafe { + if let Ok(guard) = wait_handle.lock() + && let Some(handle) = guard.as_ref() + { + let _ = TerminateProcess(*handle, 1); + } + } + } + if let Some(hpc) = hpc_for_wait + && let Ok(mut guard) = hpc.lock() + && let Some(hpc) = guard.take() + { + unsafe { + ClosePseudoConsole(hpc); + } + } + unsafe { + if token_handle != 0 && token_handle != INVALID_HANDLE_VALUE { + CloseHandle(token_handle); + } + } + finalize_exit( + exit_tx, + wait_handle, + pi.hThread, + output_join, + guards_for_wait, + cap_sid_for_wait, + common.logs_base_dir.as_deref(), + command_for_wait, + ); + }); + + let terminator = { + let process_handle = Arc::clone(&process_handle); + Some(Box::new(move || { + if let Ok(guard) = process_handle.lock() + && let Some(handle) = guard.as_ref() + { + unsafe { + let _ = TerminateProcess(*handle, 1); + } + } + }) as Box) + }; + + let driver = ProcessDriver { + writer_tx, + stdout_rx, + stderr_rx: stderr_rx.map(|(_tx, rx)| rx), + exit_rx, + terminator, + writer_handle: Some(writer_handle), + resizer: hpc_handle.map(|hpc| { + Box::new(move |size| resize_conpty_handle(&hpc, size)) + as Box Result<()> + Send> + }), + }; + + Ok(finish_driver_spawn(driver, stdin_open)) +} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/mod.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/mod.rs new file mode 100644 index 0000000000..9d2aaa9070 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod elevated; +pub(crate) mod legacy; +pub(crate) mod windows_common; diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs new file mode 100644 index 0000000000..3396b4ed98 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/windows_common.rs @@ -0,0 +1,191 @@ +use crate::ipc_framed::EmptyPayload; +use crate::ipc_framed::FramedMessage; +use crate::ipc_framed::Message; +use crate::ipc_framed::OutputStream; +use crate::ipc_framed::ResizePayload; +use crate::ipc_framed::StdinPayload; +use crate::ipc_framed::decode_bytes; +use crate::ipc_framed::encode_bytes; +use anyhow::Result; +use codex_utils_pty::ProcessDriver; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use codex_utils_pty::spawn_from_driver; +use std::fs::File; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +pub(crate) fn finish_driver_spawn(driver: ProcessDriver, stdin_open: bool) -> SpawnedProcess { + let spawned = spawn_from_driver(driver); + if !stdin_open { + spawned.session.close_stdin(); + } + spawned +} + +pub(crate) fn normalize_windows_tty_input(bytes: &[u8], previous_was_cr: &mut bool) -> Vec { + let mut normalized = Vec::with_capacity(bytes.len()); + for &byte in bytes { + if byte == b'\n' { + if !*previous_was_cr { + normalized.push(b'\r'); + } + normalized.push(b'\n'); + *previous_was_cr = false; + } else { + normalized.push(byte); + *previous_was_cr = byte == b'\r'; + } + } + normalized +} + +pub(crate) fn start_runner_pipe_writer( + mut pipe_write: File, +) -> std::sync::mpsc::Sender { + let (outbound_tx, outbound_rx) = std::sync::mpsc::channel::(); + tokio::task::spawn_blocking(move || { + while let Ok(msg) = outbound_rx.recv() { + if crate::ipc_framed::write_frame(&mut pipe_write, &msg).is_err() { + break; + } + } + }); + outbound_tx +} + +pub(crate) fn start_runner_stdin_writer( + mut writer_rx: mpsc::Receiver>, + outbound_tx: std::sync::mpsc::Sender, + normalize_newlines: bool, + stdin_open: bool, +) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_blocking(move || { + let mut previous_was_cr = false; + while let Some(bytes) = writer_rx.blocking_recv() { + let bytes = if normalize_newlines { + normalize_windows_tty_input(&bytes, &mut previous_was_cr) + } else { + bytes + }; + let msg = FramedMessage { + version: 1, + message: Message::Stdin { + payload: StdinPayload { + data_b64: encode_bytes(&bytes), + }, + }, + }; + if outbound_tx.send(msg).is_err() { + break; + } + } + if stdin_open { + let _ = outbound_tx.send(FramedMessage { + version: 1, + message: Message::CloseStdin { + payload: EmptyPayload::default(), + }, + }); + } + }) +} + +pub(crate) fn start_runner_stdout_reader( + mut pipe_read: File, + stdout_tx: broadcast::Sender>, + stderr_tx: Option>>, + exit_tx: oneshot::Sender, +) { + std::thread::spawn(move || { + loop { + let msg = match crate::ipc_framed::read_frame(&mut pipe_read) { + Ok(Some(v)) => v, + Ok(None) => { + send_runner_error( + "runner pipe closed before exit", + &stdout_tx, + stderr_tx.as_ref(), + ); + let _ = exit_tx.send(-1); + break; + } + Err(err) => { + send_runner_error( + &format!("runner read failed: {err}"), + &stdout_tx, + stderr_tx.as_ref(), + ); + let _ = exit_tx.send(-1); + break; + } + }; + + match msg.message { + Message::Output { payload } => { + if let Ok(data) = decode_bytes(&payload.data_b64) { + match payload.stream { + OutputStream::Stdout => { + let _ = stdout_tx.send(data); + } + OutputStream::Stderr => { + if let Some(stderr_tx) = stderr_tx.as_ref() { + let _ = stderr_tx.send(data); + } else { + let _ = stdout_tx.send(data); + } + } + } + } + } + Message::Exit { payload } => { + let _ = exit_tx.send(payload.exit_code); + break; + } + Message::Error { payload } => { + send_runner_error(&payload.message, &stdout_tx, stderr_tx.as_ref()); + let _ = exit_tx.send(-1); + break; + } + Message::SpawnReady { .. } + | Message::Stdin { .. } + | Message::CloseStdin { .. } + | Message::Resize { .. } + | Message::SpawnRequest { .. } + | Message::Terminate { .. } => {} + } + } + }); +} + +pub(crate) fn make_runner_resizer( + outbound_tx: std::sync::mpsc::Sender, +) -> Box Result<()> + Send> { + Box::new(move |size: TerminalSize| { + outbound_tx + .send(FramedMessage { + version: 1, + message: Message::Resize { + payload: ResizePayload { + rows: size.rows, + cols: size.cols, + }, + }, + }) + .map_err(|_| anyhow::anyhow!("runner resize pipe closed")) + }) +} + +fn send_runner_error( + message: &str, + stdout_tx: &broadcast::Sender>, + stderr_tx: Option<&broadcast::Sender>>, +) { + let formatted = format!("runner error: {message}\n").into_bytes(); + if let Some(stderr_tx) = stderr_tx { + let _ = stderr_tx.send(formatted); + } else { + let _ = stdout_tx.send(formatted); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/session.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/session.rs new file mode 100644 index 0000000000..ced69433d1 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/session.rs @@ -0,0 +1,83 @@ +//! Unified exec session spawner for Windows sandboxing. +//! +//! This module is the thin orchestration layer for Windows unified-exec sessions. +//! Backend-specific mechanics live in sibling modules: +//! - `backends::legacy` adapts the direct restricted-token spawn path into a live session. +//! - `backends::elevated` adapts the elevated command-runner IPC path into the same session API. +//! - `backends::windows_common` holds the small shared Windows backend helpers +//! used by both. + +mod backends; + +use anyhow::Result; +use codex_utils_pty::SpawnedProcess; +use std::collections::HashMap; +use std::path::Path; + +#[allow(clippy::too_many_arguments)] +pub async fn spawn_windows_sandbox_session_legacy( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + env_map: HashMap, + timeout_ms: Option, + tty: bool, + stdin_open: bool, + use_private_desktop: bool, +) -> Result { + backends::legacy::spawn_windows_sandbox_session_legacy( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + env_map, + timeout_ms, + tty, + stdin_open, + use_private_desktop, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn spawn_windows_sandbox_session_elevated( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + env_map: HashMap, + timeout_ms: Option, + tty: bool, + stdin_open: bool, + use_private_desktop: bool, +) -> Result { + backends::elevated::spawn_windows_sandbox_session_elevated( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + env_map, + timeout_ms, + tty, + stdin_open, + use_private_desktop, + ) + .await +} + +#[cfg(test)] +pub(crate) use backends::windows_common::finish_driver_spawn; +#[cfg(test)] +pub(crate) use backends::windows_common::make_runner_resizer; +#[cfg(test)] +pub(crate) use backends::windows_common::start_runner_pipe_writer; +#[cfg(test)] +pub(crate) use backends::windows_common::start_runner_stdin_writer; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs new file mode 100644 index 0000000000..d9de8104aa --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -0,0 +1,533 @@ +#![cfg(target_os = "windows")] + +use super::spawn_windows_sandbox_session_legacy; +use crate::ipc_framed::Message; +use crate::ipc_framed::decode_bytes; +use crate::ipc_framed::read_frame; +use crate::run_windows_sandbox_capture; +use codex_utils_pty::ProcessDriver; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::fs; +use std::fs::OpenOptions; +use std::io::Seek; +use std::io::SeekFrom; +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; +use tempfile::TempDir; +use tokio::runtime::Builder; +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::timeout; + +static TEST_HOME_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn current_thread_runtime() -> tokio::runtime::Runtime { + Builder::new_current_thread() + .enable_all() + .build() + .expect("build tokio runtime") +} + +fn pwsh_path() -> Option { + let program_files = std::env::var_os("ProgramFiles")?; + let path = PathBuf::from(program_files).join("PowerShell\\7\\pwsh.exe"); + path.is_file().then_some(path) +} + +fn sandbox_cwd() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .to_path_buf() +} + +fn sandbox_home(name: &str) -> TempDir { + let id = TEST_HOME_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("codex-windows-sandbox-{name}-{id}")); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create sandbox home"); + tempfile::TempDir::new_in(&path).expect("create sandbox home tempdir") +} + +fn sandbox_log(codex_home: &Path) -> String { + let log_path = codex_home.join(".sandbox").join("sandbox.log"); + fs::read_to_string(&log_path) + .unwrap_or_else(|err| format!("failed to read {}: {err}", log_path.display())) +} + +fn wait_for_frame_count(frames_path: &Path, expected_frames: usize) -> Vec { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let mut reader = OpenOptions::new() + .read(true) + .open(frames_path) + .expect("open frame file for read"); + reader + .seek(SeekFrom::Start(0)) + .expect("seek to start of frame file"); + + let mut frames = Vec::new(); + loop { + match read_frame(&mut reader) { + Ok(Some(frame)) => frames.push(frame.message), + Ok(None) => break, + Err(_) => break, + } + } + + if frames.len() >= expected_frames { + return frames; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {expected_frames} frames, saw {}", + frames.len() + ); + std::thread::sleep(Duration::from_millis(10)); + } +} + +async fn collect_stdout_and_exit( + spawned: codex_utils_pty::SpawnedProcess, + codex_home: &Path, + timeout_duration: Duration, +) -> (Vec, i32) { + let codex_utils_pty::SpawnedProcess { + session: _session, + mut stdout_rx, + stderr_rx: _stderr_rx, + exit_rx, + } = spawned; + let stdout_task = tokio::spawn(async move { + let mut stdout = Vec::new(); + while let Some(chunk) = stdout_rx.recv().await { + stdout.extend(chunk); + } + stdout + }); + let exit_code = timeout(timeout_duration, exit_rx) + .await + .unwrap_or_else(|_| panic!("timed out waiting for exit\n{}", sandbox_log(codex_home))) + .unwrap_or(-1); + let stdout = timeout(timeout_duration, stdout_task) + .await + .unwrap_or_else(|_| { + panic!( + "timed out waiting for stdout task\n{}", + sandbox_log(codex_home) + ) + }) + .expect("stdout task join"); + (stdout, exit_code) +} + +#[test] +fn legacy_non_tty_cmd_emits_output() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let cwd = sandbox_cwd(); + let codex_home = sandbox_home("legacy-non-tty-cmd"); + println!("cmd codex_home={}", codex_home.path().display()); + let spawned = spawn_windows_sandbox_session_legacy( + "workspace-write", + cwd.as_path(), + codex_home.path(), + vec![ + "C:\\Windows\\System32\\cmd.exe".to_string(), + "/c".to_string(), + "echo LEGACY-NONTTY-CMD".to_string(), + ], + cwd.as_path(), + HashMap::new(), + Some(5_000), + /*tty*/ false, + /*stdin_open*/ false, + /*use_private_desktop*/ true, + ) + .await + .expect("spawn legacy non-tty cmd session"); + println!("cmd spawn returned"); + let (stdout, exit_code) = + collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(10)).await; + println!("cmd collect returned exit_code={exit_code}"); + let stdout = String::from_utf8_lossy(&stdout); + assert_eq!(exit_code, 0, "stdout={stdout:?}"); + assert!(stdout.contains("LEGACY-NONTTY-CMD"), "stdout={stdout:?}"); + }); +} + +#[test] +fn legacy_non_tty_powershell_emits_output() { + let Some(pwsh) = pwsh_path() else { + return; + }; + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let cwd = sandbox_cwd(); + let codex_home = sandbox_home("legacy-non-tty-pwsh"); + println!("pwsh codex_home={}", codex_home.path().display()); + let spawned = spawn_windows_sandbox_session_legacy( + "workspace-write", + cwd.as_path(), + codex_home.path(), + vec![ + pwsh.display().to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "Write-Output LEGACY-NONTTY-DIRECT".to_string(), + ], + cwd.as_path(), + HashMap::new(), + Some(5_000), + /*tty*/ false, + /*stdin_open*/ false, + /*use_private_desktop*/ true, + ) + .await + .expect("spawn legacy non-tty powershell session"); + println!("pwsh spawn returned"); + let (stdout, exit_code) = + collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(10)).await; + println!("pwsh collect returned exit_code={exit_code}"); + let stdout = String::from_utf8_lossy(&stdout); + assert_eq!(exit_code, 0, "stdout={stdout:?}"); + assert!(stdout.contains("LEGACY-NONTTY-DIRECT"), "stdout={stdout:?}"); + }); +} + +#[test] +fn finish_driver_spawn_keeps_stdin_open_when_requested() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let (writer_tx, mut writer_rx) = mpsc::channel::>(1); + let (_stdout_tx, stdout_rx) = broadcast::channel::>(1); + let (exit_tx, exit_rx) = oneshot::channel::(); + drop(exit_tx); + + let spawned = super::finish_driver_spawn( + ProcessDriver { + writer_tx, + stdout_rx, + stderr_rx: None, + exit_rx, + terminator: None, + writer_handle: None, + resizer: None, + }, + /*stdin_open*/ true, + ); + + spawned + .session + .writer_sender() + .send(b"open".to_vec()) + .await + .expect("stdin should stay open"); + assert_eq!(writer_rx.recv().await, Some(b"open".to_vec())); + }); +} + +#[test] +fn finish_driver_spawn_closes_stdin_when_not_requested() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let (writer_tx, _writer_rx) = mpsc::channel::>(1); + let (_stdout_tx, stdout_rx) = broadcast::channel::>(1); + let (exit_tx, exit_rx) = oneshot::channel::(); + drop(exit_tx); + + let spawned = super::finish_driver_spawn( + ProcessDriver { + writer_tx, + stdout_rx, + stderr_rx: None, + exit_rx, + terminator: None, + writer_handle: None, + resizer: None, + }, + /*stdin_open*/ false, + ); + + assert!( + spawned + .session + .writer_sender() + .send(b"closed".to_vec()) + .await + .is_err(), + "stdin should be closed when streaming input is disabled" + ); + }); +} + +#[test] +fn runner_stdin_writer_sends_close_stdin_after_input_eof() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let tempdir = TempDir::new().expect("create tempdir"); + let frames_path = tempdir.path().join("runner-stdin-frames.bin"); + let file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&frames_path) + .expect("create frame file"); + let outbound_tx = super::start_runner_pipe_writer(file); + let (writer_tx, writer_rx) = mpsc::channel::>(1); + let writer_handle = super::start_runner_stdin_writer( + writer_rx, + outbound_tx, + /*normalize_newlines*/ false, + /*stdin_open*/ true, + ); + + writer_tx + .send(b"hello".to_vec()) + .await + .expect("send stdin bytes"); + drop(writer_tx); + writer_handle.await.expect("join stdin writer"); + + let frames = wait_for_frame_count(&frames_path, 2); + + match &frames[0] { + Message::Stdin { payload } => { + let bytes = decode_bytes(&payload.data_b64).expect("decode stdin payload"); + assert_eq!(bytes, b"hello".to_vec()); + } + other => panic!("expected stdin frame, got {other:?}"), + } + + match &frames[1] { + Message::CloseStdin { .. } => {} + other => panic!("expected close-stdin frame, got {other:?}"), + } + }); +} + +#[test] +fn runner_resizer_sends_resize_frame() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let tempdir = TempDir::new().expect("create tempdir"); + let frames_path = tempdir.path().join("runner-resize-frames.bin"); + let file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&frames_path) + .expect("create frame file"); + let outbound_tx = super::start_runner_pipe_writer(file); + let mut resizer = super::make_runner_resizer(outbound_tx); + + resizer(codex_utils_pty::TerminalSize { + rows: 45, + cols: 132, + }) + .expect("send resize frame"); + + let frames = wait_for_frame_count(&frames_path, 1); + match &frames[0] { + Message::Resize { payload } => { + assert_eq!(payload.rows, 45); + assert_eq!(payload.cols, 132); + } + other => panic!("expected resize frame, got {other:?}"), + } + }); +} + +#[test] +fn legacy_capture_powershell_emits_output() { + let Some(pwsh) = pwsh_path() else { + return; + }; + let cwd = sandbox_cwd(); + let codex_home = sandbox_home("legacy-capture-pwsh"); + println!("capture pwsh codex_home={}", codex_home.path().display()); + let result = run_windows_sandbox_capture( + "workspace-write", + cwd.as_path(), + codex_home.path(), + vec![ + pwsh.display().to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "Write-Output LEGACY-CAPTURE-DIRECT".to_string(), + ], + cwd.as_path(), + HashMap::new(), + Some(10_000), + /*use_private_desktop*/ true, + ) + .expect("run legacy capture powershell"); + println!("capture pwsh exit_code={}", result.exit_code); + println!("capture pwsh timed_out={}", result.timed_out); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + println!("capture pwsh stderr={stderr:?}"); + assert_eq!(result.exit_code, 0, "stdout={stdout:?} stderr={stderr:?}"); + assert!( + stdout.contains("LEGACY-CAPTURE-DIRECT"), + "stdout={stdout:?}" + ); +} + +#[test] +fn legacy_tty_powershell_emits_output_and_accepts_input() { + let Some(pwsh) = pwsh_path() else { + return; + }; + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let cwd = sandbox_cwd(); + let codex_home = sandbox_home("legacy-tty-pwsh"); + println!("tty pwsh codex_home={}", codex_home.path().display()); + let spawned = spawn_windows_sandbox_session_legacy( + "workspace-write", + cwd.as_path(), + codex_home.path(), + vec![ + pwsh.display().to_string(), + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-NoExit".to_string(), + "-Command".to_string(), + "$PID; Write-Output ready".to_string(), + ], + cwd.as_path(), + HashMap::new(), + Some(10_000), + /*tty*/ true, + /*stdin_open*/ true, + /*use_private_desktop*/ true, + ) + .await + .expect("spawn legacy tty powershell session"); + println!("tty pwsh spawn returned"); + + let writer = spawned.session.writer_sender(); + writer + .send(b"Write-Output second\n".to_vec()) + .await + .expect("send second command"); + writer + .send(b"exit\n".to_vec()) + .await + .expect("send exit command"); + spawned.session.close_stdin(); + + let (stdout, exit_code) = + collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(15)).await; + let stdout = String::from_utf8_lossy(&stdout); + assert_eq!(exit_code, 0, "stdout={stdout:?}"); + assert!(stdout.contains("ready"), "stdout={stdout:?}"); + assert!(stdout.contains("second"), "stdout={stdout:?}"); + }); +} + +#[test] +fn legacy_tty_cmd_emits_output_and_accepts_input() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let cwd = sandbox_cwd(); + let codex_home = sandbox_home("legacy-tty-cmd"); + println!("tty cmd codex_home={}", codex_home.path().display()); + let spawned = spawn_windows_sandbox_session_legacy( + "workspace-write", + cwd.as_path(), + codex_home.path(), + vec![ + "C:\\Windows\\System32\\cmd.exe".to_string(), + "/K".to_string(), + "echo ready".to_string(), + ], + cwd.as_path(), + HashMap::new(), + Some(10_000), + /*tty*/ true, + /*stdin_open*/ true, + /*use_private_desktop*/ true, + ) + .await + .expect("spawn legacy tty cmd session"); + println!("tty cmd spawn returned"); + + let writer = spawned.session.writer_sender(); + writer + .send(b"echo second\n".to_vec()) + .await + .expect("send second command"); + writer + .send(b"exit\n".to_vec()) + .await + .expect("send exit command"); + spawned.session.close_stdin(); + + let (stdout, exit_code) = + collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(15)).await; + let stdout = String::from_utf8_lossy(&stdout); + assert_eq!(exit_code, 0, "stdout={stdout:?}"); + assert!(stdout.contains("ready"), "stdout={stdout:?}"); + assert!(stdout.contains("second"), "stdout={stdout:?}"); + }); +} + +#[test] +fn legacy_tty_cmd_default_desktop_emits_output_and_accepts_input() { + let runtime = current_thread_runtime(); + runtime.block_on(async move { + let cwd = sandbox_cwd(); + let codex_home = sandbox_home("legacy-tty-cmd-default-desktop"); + println!( + "tty cmd default desktop codex_home={}", + codex_home.path().display() + ); + let spawned = spawn_windows_sandbox_session_legacy( + "workspace-write", + cwd.as_path(), + codex_home.path(), + vec![ + "C:\\Windows\\System32\\cmd.exe".to_string(), + "/K".to_string(), + "echo ready".to_string(), + ], + cwd.as_path(), + HashMap::new(), + Some(10_000), + /*tty*/ true, + /*stdin_open*/ true, + /*use_private_desktop*/ false, + ) + .await + .expect("spawn legacy tty cmd session"); + println!("tty cmd default desktop spawn returned"); + + let writer = spawned.session.writer_sender(); + writer + .send(b"echo second\n".to_vec()) + .await + .expect("send second command"); + writer + .send(b"exit\n".to_vec()) + .await + .expect("send exit command"); + spawned.session.close_stdin(); + + let (stdout, exit_code) = + collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(15)).await; + let stdout = String::from_utf8_lossy(&stdout); + assert_eq!(exit_code, 0, "stdout={stdout:?}"); + assert!(stdout.contains("ready"), "stdout={stdout:?}"); + assert!(stdout.contains("second"), "stdout={stdout:?}"); + }); +} diff --git a/codex-rs/windows-sandbox-rs/src/winutil.rs b/codex-rs/windows-sandbox-rs/src/winutil.rs index 1486c7f857..f014e06072 100644 --- a/codex-rs/windows-sandbox-rs/src/winutil.rs +++ b/codex-rs/windows-sandbox-rs/src/winutil.rs @@ -64,6 +64,15 @@ pub fn quote_windows_arg(arg: &str) -> String { quoted } +/// Build a Windows command line for CreateProcess-style APIs. +#[cfg(target_os = "windows")] +pub fn argv_to_command_line(argv: &[String]) -> String { + argv.iter() + .map(|arg| quote_windows_arg(arg)) + .collect::>() + .join(" ") +} + // Produce a readable description for a Win32 error code. pub fn format_last_error(err: i32) -> String { unsafe { @@ -190,3 +199,38 @@ fn sid_bytes_from_string(sid_str: &str) -> Result> { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::argv_to_command_line; + use pretty_assertions::assert_eq; + + #[test] + fn argv_to_command_line_quotes_each_argument_independently() { + let argv = vec![ + "cmd.exe".to_string(), + "/c".to_string(), + "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -NoProfile -EncodedCommand abc==" + .to_string(), + ]; + + assert_eq!( + argv_to_command_line(&argv), + "cmd.exe /c \"\\\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\\\" -NoProfile -EncodedCommand abc==\"" + ); + } + + #[test] + fn argv_to_command_line_quotes_regular_program_args() { + let argv = vec![ + "pwsh.exe".to_string(), + "-Command".to_string(), + "Write-Output \"hello world\"".to_string(), + ]; + + assert_eq!( + argv_to_command_line(&argv), + "pwsh.exe -Command \"Write-Output \\\"hello world\\\"\"" + ); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs index d011db30b5..7143212b54 100644 --- a/codex-rs/windows-sandbox-rs/src/workspace_acl.rs +++ b/codex-rs/windows-sandbox-rs/src/workspace_acl.rs @@ -1,6 +1,30 @@ +use crate::acl::add_deny_write_ace; use crate::path_normalization::canonicalize_path; +use anyhow::Result; +use std::ffi::c_void; use std::path::Path; pub fn is_command_cwd_root(root: &Path, canonical_command_cwd: &Path) -> bool { canonicalize_path(root) == canonical_command_cwd } + +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. +pub unsafe fn protect_workspace_codex_dir(cwd: &Path, psid: *mut c_void) -> Result { + protect_workspace_subdir(cwd, psid, ".codex") +} + +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. +pub unsafe fn protect_workspace_agents_dir(cwd: &Path, psid: *mut c_void) -> Result { + protect_workspace_subdir(cwd, psid, ".agents") +} + +unsafe fn protect_workspace_subdir(cwd: &Path, psid: *mut c_void, subdir: &str) -> Result { + let path = cwd.join(subdir); + if path.is_dir() { + add_deny_write_ace(&path, psid) + } else { + Ok(false) + } +}