#![cfg(target_os = "windows")] use super::spawn_windows_sandbox_session_elevated_for_permission_profile; use super::spawn_windows_sandbox_session_legacy; use crate::WindowsSandboxCancellationToken; use crate::ipc_framed::Message; use crate::ipc_framed::decode_bytes; use crate::ipc_framed::read_frame; use crate::run_windows_sandbox_capture; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; 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::os::windows::io::AsRawHandle; use std::os::windows::io::FromRawHandle; use std::os::windows::io::OwnedHandle; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::MutexGuard; 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; use windows_sys::Win32::Foundation::WAIT_FAILED; use windows_sys::Win32::Foundation::WAIT_OBJECT_0; use windows_sys::Win32::Foundation::WAIT_TIMEOUT; use windows_sys::Win32::System::Threading::OpenProcess; use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE; use windows_sys::Win32::System::Threading::WaitForSingleObject; static TEST_HOME_COUNTER: AtomicU64 = AtomicU64::new(0); static LEGACY_PROCESS_TEST_LOCK: Mutex<()> = Mutex::new(()); fn legacy_process_test_guard() -> MutexGuard<'static, ()> { LEGACY_PROCESS_TEST_LOCK .lock() .expect("legacy Windows sandbox process test lock poisoned") } 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 { if let Ok(workspace_root) = std::env::var("INSTA_WORKSPACE_ROOT") { return PathBuf::from(workspace_root); } 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 = crate::current_log_file_path(&codex_home.join(".sandbox")); fs::read_to_string(&log_path) .unwrap_or_else(|err| format!("failed to read {}: {err}", log_path.display())) } fn workspace_roots_for(root: &Path) -> Vec { vec![AbsolutePathBuf::from_absolute_path(root).expect("absolute workspace root")] } fn powershell_literal(path: &Path) -> String { path.to_string_lossy().replace('\'', "''") } fn start_powershell_child( pwsh: &Path, stdio_dir: &Path, child_command: &str, parent_tail: &str, ) -> String { let encoded = BASE64.encode( child_command .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(), ); format!( "Start-Process -WindowStyle Hidden -FilePath '{}' -ArgumentList '-NoProfile','-EncodedCommand','{encoded}' -RedirectStandardOutput '{}' -RedirectStandardError '{}'; {parent_tail}", powershell_literal(pwsh), powershell_literal(&stdio_dir.join("descendant.stdout")), powershell_literal(&stdio_dir.join("descendant.stderr")), ) } fn wait_for_path(path: &Path, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { if path.exists() { return true; } std::thread::sleep(Duration::from_millis(25)); } path.exists() } fn open_process_for_wait(pid: u32) -> std::io::Result { let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; if handle == 0 { return Err(std::io::Error::last_os_error()); } Ok(unsafe { OwnedHandle::from_raw_handle(handle as _) }) } fn wait_for_process_exit(process: &OwnedHandle, timeout: Duration) -> std::io::Result<()> { let timeout_ms = u32::try_from(timeout.as_millis()) .map_err(|_| std::io::Error::other("process wait timeout exceeds u32"))?; let result = unsafe { WaitForSingleObject(process.as_raw_handle() as _, timeout_ms) }; match result { WAIT_OBJECT_0 => Ok(()), WAIT_TIMEOUT => Err(std::io::Error::new( std::io::ErrorKind::TimedOut, "timed out waiting for process to exit", )), WAIT_FAILED => Err(std::io::Error::last_os_error()), result => Err(std::io::Error::other(format!( "unexpected process wait result: {result}" ))), } } 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 _guard = legacy_process_test_guard(); 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 permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), 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 elevated_non_tty_cmd_forwards_env_output_and_exit() { let _guard = legacy_process_test_guard(); let runtime = current_thread_runtime(); runtime.block_on(async move { let cwd = sandbox_cwd(); let codex_home = sandbox_home("elevated-non-tty-cmd"); let permission_profile = PermissionProfile::workspace_write(); let env_map = HashMap::from([( "CODEX_ELEVATED_TEST".to_string(), "ELEVATED-ENV-OK".to_string(), )]); let spawned = spawn_windows_sandbox_session_elevated_for_permission_profile( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), codex_home.path(), vec![ "C:\\Windows\\System32\\cmd.exe".to_string(), "/d".to_string(), "/c".to_string(), "echo %CODEX_ELEVATED_TEST% & exit /b 23".to_string(), ], cwd.as_path(), env_map, /*proxy_enforced*/ false, /*network_proxy_restricting_sid*/ None, Some(5_000), /*read_roots_override*/ None, /*read_roots_include_platform_defaults*/ true, /*write_roots_override*/ None, &[], &[], /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, ) .await .expect("spawn elevated non-tty cmd session"); let (stdout, exit_code) = collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(10)).await; let stdout = String::from_utf8_lossy(&stdout); assert_eq!(exit_code, 23, "stdout={stdout:?}"); assert!(stdout.contains("ELEVATED-ENV-OK"), "stdout={stdout:?}"); }); } #[test] fn legacy_non_tty_cmd_rejects_deny_read_overrides() { let _guard = legacy_process_test_guard(); let runtime = current_thread_runtime(); runtime.block_on(async move { let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-non-tty-deny-read"); let secret_path = AbsolutePathBuf::from_absolute_path(cwd.join("legacy-non-tty-deny-read-secret.env")) .expect("absolute deny-read fixture path"); let permission_profile = PermissionProfile::workspace_write(); let err = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), codex_home.path(), vec![ "C:\\Windows\\System32\\cmd.exe".to_string(), "/c".to_string(), "echo deny-read".to_string(), ], cwd.as_path(), HashMap::new(), Some(5_000), std::slice::from_ref(&secret_path), &[], /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, ) .await .expect_err("legacy deny-read should require the elevated backend"); assert!( err.to_string() .contains("deny-read overrides require the elevated Windows sandbox backend"), "unexpected error: {err:#}" ); }); } #[test] fn legacy_non_tty_powershell_emits_output() { let Some(pwsh) = pwsh_path() else { return; }; let _guard = legacy_process_test_guard(); 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 permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), 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_emits_output_and_preserves_descendant_after_normal_exit() { let Some(pwsh) = pwsh_path() else { return; }; let _guard = legacy_process_test_guard(); let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-capture-pwsh"); println!("capture pwsh codex_home={}", codex_home.path().display()); let ready_marker = codex_home.path().join("descendant-started"); let release_marker = codex_home.path().join("release-descendant"); let survival_marker = codex_home.path().join("descendant-survived"); let descendant_command = format!( "$deadline=(Get-Date).AddSeconds(30); Set-Content -LiteralPath '{}' -Value $PID; while (-not (Test-Path -LiteralPath '{}')) {{ if ((Get-Date) -ge $deadline) {{ exit 3 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{}' -Value survived", powershell_literal(&ready_marker), powershell_literal(&release_marker), powershell_literal(&survival_marker), ); let parent_tail = format!( "while (-not (Test-Path -LiteralPath '{}')) {{ Start-Sleep -Milliseconds 25 }}", powershell_literal(&ready_marker), ); let parent_command = format!( "Write-Output LEGACY-CAPTURE-DIRECT; {}", start_powershell_child(&pwsh, codex_home.path(), &descendant_command, &parent_tail,), ); let permission_profile = PermissionProfile::workspace_write(); let result = run_windows_sandbox_capture( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), codex_home.path(), vec![ pwsh.display().to_string(), "-NoProfile".to_string(), "-Command".to_string(), parent_command, ], cwd.as_path(), HashMap::new(), Some(10_000), /*cancellation*/ None, /*use_private_desktop*/ true, ) .expect("run legacy capture powershell"); let descendant_pid = fs::read_to_string(&ready_marker) .expect("read descendant pid") .trim() .parse() .expect("parse descendant pid"); let descendant_process = open_process_for_wait(descendant_pid); fs::write(&release_marker, "release").expect("release descendant after root exit"); let descendant_process = descendant_process.expect("open descendant after normal capture exit"); 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:?}" ); assert!( wait_for_path(&survival_marker, Duration::from_secs(10)), "sandbox descendant did not survive normal capture exit" ); wait_for_process_exit(&descendant_process, Duration::from_secs(10)) .expect("sandbox descendant did not exit after release"); } #[test] fn legacy_workspace_write_delete_is_limited_to_writable_roots() { let _guard = legacy_process_test_guard(); let runtime = current_thread_runtime(); runtime.block_on(async move { // Keep writable roots out of USERPROFILE exclusions such as AppData. let test_root = TempDir::new_in(sandbox_cwd()).expect("create legacy delete test root"); let codex_home = sandbox_home("legacy-delete-writable-roots"); let workspace = test_root.path().join("workspace"); let temp_root = test_root.path().join("temp"); let tmp_root = test_root.path().join("tmp"); let outside_root = test_root.path().join("outside"); for directory in [&workspace, &temp_root, &tmp_root, &outside_root] { fs::create_dir_all(directory).expect("create legacy delete test directory"); } let protected_git_dir = workspace.join(".git"); fs::create_dir(&protected_git_dir).expect("create protected .git directory"); let workspace_file = workspace.join("workspace-delete.txt"); let temp_file = temp_root.join("temp-delete.txt"); let tmp_file = tmp_root.join("tmp-delete.txt"); let outside_file = outside_root.join("outside-delete.txt"); fs::write(&workspace_file, "workspace").expect("seed workspace file"); fs::write(&temp_file, "temp").expect("seed TEMP file"); fs::write(&tmp_file, "tmp").expect("seed TMP file"); fs::write(&outside_file, "outside").expect("seed outside file"); let script = workspace.join("delete-fixtures.cmd"); fs::write( &script, concat!( "@echo off\r\n", "del /f /q \"%WORKSPACE_DELETE%\"\r\n", "del /f /q \"%TEMP_DELETE%\"\r\n", "del /f /q \"%TMP_DELETE%\"\r\n", "del /f /q \"%OUTSIDE_DELETE%\"\r\n", "rmdir \"%PROTECTED_GIT_DIR%\"\r\n", "exit /b 0\r\n", ), ) .expect("write delete script"); let env_map = HashMap::from([ ("TEMP".to_string(), temp_root.to_string_lossy().into_owned()), ("TMP".to_string(), tmp_root.to_string_lossy().into_owned()), ( "WORKSPACE_DELETE".to_string(), workspace_file.to_string_lossy().into_owned(), ), ( "TEMP_DELETE".to_string(), temp_file.to_string_lossy().into_owned(), ), ( "TMP_DELETE".to_string(), tmp_file.to_string_lossy().into_owned(), ), ( "OUTSIDE_DELETE".to_string(), outside_file.to_string_lossy().into_owned(), ), ( "PROTECTED_GIT_DIR".to_string(), protected_git_dir.to_string_lossy().into_owned(), ), ]); let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(workspace.as_path()).as_slice(), codex_home.path(), vec![ "C:\\Windows\\System32\\cmd.exe".to_string(), "/d".to_string(), "/c".to_string(), script.display().to_string(), ], workspace.as_path(), env_map, /*timeout_ms*/ Some(5_000), &[], &[], /*tty*/ false, /*stdin_open*/ false, /*use_private_desktop*/ true, ) .await .expect("spawn legacy delete session"); let (stdout, exit_code) = collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(/*secs*/ 10)) .await; let stdout = String::from_utf8_lossy(&stdout); assert_eq!( ( exit_code, workspace_file.exists(), temp_file.exists(), tmp_file.exists(), fs::read_to_string(&outside_file).ok(), protected_git_dir.is_dir(), ), (0, false, false, false, Some("outside".to_string()), true), "stdout={stdout:?}\n{}", sandbox_log(codex_home.path()) ); }); } #[test] fn legacy_capture_cancellation_terminates_descendants_without_timeout() { let Some(pwsh) = pwsh_path() else { eprintln!("skipping cancellation regression test: PowerShell 7 is not installed"); return; }; let _guard = legacy_process_test_guard(); let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-capture-cancel"); let descendant_marker = codex_home.path().join("descendant-survived"); let ready_marker = codex_home.path().join("descendant-started"); let descendant_command = format!( "Set-Content -LiteralPath '{}' -Value $PID; Start-Sleep -Seconds 1; Set-Content -LiteralPath '{}' -Value survived", powershell_literal(&ready_marker), powershell_literal(&descendant_marker), ); let parent_command = start_powershell_child( &pwsh, codex_home.path(), &descendant_command, "Start-Sleep -Seconds 30", ); let descendant_process = Arc::new(Mutex::new(None)); let descendant_process_for_cancellation = Arc::clone(&descendant_process); let cancellation = WindowsSandboxCancellationToken::new(move || { let Ok(pid) = fs::read_to_string(&ready_marker).and_then(|pid| { pid.trim() .parse() .map_err(|err| std::io::Error::other(format!("invalid descendant pid: {err}"))) }) else { return false; }; let Ok(process) = open_process_for_wait(pid) else { return false; }; *descendant_process_for_cancellation .lock() .expect("descendant process lock poisoned") = Some(process); true }); let started_at = Instant::now(); let permission_profile = PermissionProfile::workspace_write(); let result = run_windows_sandbox_capture( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), codex_home.path(), vec![ pwsh.display().to_string(), "-NoProfile".to_string(), "-Command".to_string(), parent_command, ], cwd.as_path(), HashMap::new(), Some(30_000), /*cancellation*/ Some(cancellation), /*use_private_desktop*/ true, ) .expect("run legacy capture powershell with cancellation"); assert!( started_at.elapsed() < Duration::from_secs(10), "cancellation should end capture before the timeout" ); assert!( !result.timed_out, "cancellation should not be reported as a timeout" ); assert_ne!(result.exit_code, 0); let descendant_process = descendant_process .lock() .expect("descendant process lock poisoned") .take() .expect("cancellation did not capture descendant process"); wait_for_process_exit(&descendant_process, Duration::from_secs(10)) .expect("sandbox descendant did not exit after cancellation"); assert!( !descendant_marker.exists(), "sandbox descendant survived cancellation" ); } #[derive(Clone, Copy, Debug)] enum LegacyTtyDescendantLifecycle { Terminate, Preserve, } async fn assert_legacy_tty_descendant_lifecycle( pwsh: &Path, lifecycle: LegacyTtyDescendantLifecycle, ) { let cwd = sandbox_cwd(); let codex_home = sandbox_home(match lifecycle { LegacyTtyDescendantLifecycle::Terminate => "legacy-tty-descendant-terminate", LegacyTtyDescendantLifecycle::Preserve => "legacy-tty-descendant-preserve", }); let ready_marker = codex_home.path().join("descendant-started"); let release_marker = codex_home.path().join("release-descendant"); let survival_marker = codex_home.path().join("descendant-survived"); let child_tail = match lifecycle { LegacyTtyDescendantLifecycle::Terminate => "Start-Sleep -Seconds 30".to_string(), LegacyTtyDescendantLifecycle::Preserve => format!( "$deadline=(Get-Date).AddSeconds(30); while (-not (Test-Path -LiteralPath '{}')) {{ if ((Get-Date) -ge $deadline) {{ exit 3 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{}' -Value survived", powershell_literal(&release_marker), powershell_literal(&survival_marker), ), }; let child_command = format!( "Set-Content -LiteralPath '{}' -Value $PID; {child_tail}", powershell_literal(&ready_marker), ); let parent_tail = match lifecycle { LegacyTtyDescendantLifecycle::Terminate => "Start-Sleep -Seconds 30".to_string(), LegacyTtyDescendantLifecycle::Preserve => format!( "while (-not (Test-Path -LiteralPath '{}')) {{ Start-Sleep -Milliseconds 25 }}", powershell_literal(&ready_marker), ), }; let parent_command = start_powershell_child(pwsh, codex_home.path(), &child_command, &parent_tail); let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), codex_home.path(), vec![ pwsh.display().to_string(), "-NoProfile".to_string(), "-Command".to_string(), parent_command, ], cwd.as_path(), HashMap::new(), Some(30_000), &[], &[], /*tty*/ true, /*stdin_open*/ false, /*use_private_desktop*/ true, ) .await .expect("spawn legacy sandbox ConPTY lifecycle test"); assert!( wait_for_path(&ready_marker, Duration::from_secs(10)), "{lifecycle:?} descendant did not start" ); let descendant_pid = fs::read_to_string(&ready_marker) .expect("read descendant pid") .trim() .parse() .expect("parse descendant pid"); let descendant_process = open_process_for_wait(descendant_pid); if matches!(lifecycle, LegacyTtyDescendantLifecycle::Terminate) { spawned.session.request_terminate(); } let (_, exit_code) = collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(15)).await; if matches!(lifecycle, LegacyTtyDescendantLifecycle::Preserve) { fs::write(&release_marker, "release").expect("release preserved descendant"); } let descendant_process = descendant_process.expect("open sandbox ConPTY descendant"); match lifecycle { LegacyTtyDescendantLifecycle::Terminate => assert_ne!(exit_code, 0), LegacyTtyDescendantLifecycle::Preserve => { assert_eq!(exit_code, 0); assert!( wait_for_path(&survival_marker, Duration::from_secs(10)), "sandbox ConPTY descendant did not survive normal exit" ); } } wait_for_process_exit(&descendant_process, Duration::from_secs(10)) .expect("sandbox ConPTY descendant did not exit"); } #[test] fn legacy_tty_job_terminates_and_preserves_descendants() { let Some(pwsh) = pwsh_path() else { eprintln!("skipping sandbox ConPTY lifecycle test: PowerShell 7 is not installed"); return; }; let _guard = legacy_process_test_guard(); current_thread_runtime().block_on(async move { assert_legacy_tty_descendant_lifecycle(&pwsh, LegacyTtyDescendantLifecycle::Terminate) .await; assert_legacy_tty_descendant_lifecycle(&pwsh, LegacyTtyDescendantLifecycle::Preserve).await; }); } #[test] fn legacy_tty_powershell_emits_output_and_accepts_input() { let Some(pwsh) = pwsh_path() else { return; }; let _guard = legacy_process_test_guard(); 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 permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), 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] #[ignore = "TODO: legacy ConPTY cmd.exe exits with STATUS_DLL_INIT_FAILED in CI"] 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 permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), 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] #[ignore = "TODO: legacy ConPTY cmd.exe exits with STATUS_DLL_INIT_FAILED in CI"] 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 permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( &permission_profile, workspace_roots_for(cwd.as_path()).as_slice(), 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:?}"); }); }