Skip to content

Commit 6d50418

Browse files
committed
windows: contain elevated unified exec sessions
1 parent 581ceb2 commit 6d50418

7 files changed

Lines changed: 417 additions & 82 deletions

File tree

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/windows-sandbox-rs/BUILD.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@ codex_rust_crate(
66
"codex-windows-sandbox-setup.manifest",
77
],
88
crate_name = "codex_windows_sandbox",
9+
test_data_extra = [
10+
":codex-command-runner",
11+
":codex-windows-sandbox-setup",
12+
],
913
)

codex-rs/windows-sandbox-rs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,5 @@ features = [
9393
version = "0.52"
9494

9595
[dev-dependencies]
96+
codex-utils-cargo-bin = { workspace = true }
9697
pretty_assertions = { workspace = true }

codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs

Lines changed: 74 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ mod cwd_junction;
1313

1414
use anyhow::Context;
1515
use anyhow::Result;
16+
use codex_utils_pty::JobProcess;
17+
use codex_utils_pty::KillOnCloseJob;
1618
use codex_windows_sandbox::ErrorPayload;
1719
use codex_windows_sandbox::ErrorStage;
1820
use codex_windows_sandbox::ExitPayload;
1921
use codex_windows_sandbox::FramedMessage;
2022
use codex_windows_sandbox::IPC_PROTOCOL_VERSION;
23+
use codex_windows_sandbox::JobPipeSpawnHandles;
2124
use codex_windows_sandbox::LocalSid;
2225
use codex_windows_sandbox::Message;
2326
use codex_windows_sandbox::OutputPayload;
2427
use codex_windows_sandbox::OutputStream;
25-
use codex_windows_sandbox::PipeSpawnHandles;
2628
use codex_windows_sandbox::ResizePayload;
2729
use codex_windows_sandbox::SpawnReady;
2830
use codex_windows_sandbox::SpawnRequest;
@@ -39,7 +41,7 @@ use codex_windows_sandbox::hide_current_user_profile_dir;
3941
use codex_windows_sandbox::log_note;
4042
use codex_windows_sandbox::read_frame;
4143
use codex_windows_sandbox::read_handle_loop;
42-
use codex_windows_sandbox::spawn_process_with_pipes;
44+
use codex_windows_sandbox::spawn_job_process_with_pipes;
4345
use codex_windows_sandbox::to_wide;
4446
use codex_windows_sandbox::token_mode_for_permission_profile;
4547
use codex_windows_sandbox::write_frame;
@@ -62,38 +64,43 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE;
6264
use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING;
6365
use windows_sys::Win32::System::Console::COORD;
6466
use windows_sys::Win32::System::Console::ResizePseudoConsole;
65-
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
66-
use windows_sys::Win32::System::JobObjects::CreateJobObjectW;
67-
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
68-
use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION;
69-
use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation;
70-
use windows_sys::Win32::System::JobObjects::SetInformationJobObject;
7167
use windows_sys::Win32::System::Threading::GetExitCodeProcess;
72-
use windows_sys::Win32::System::Threading::GetProcessId;
7368
use windows_sys::Win32::System::Threading::INFINITE;
7469
use windows_sys::Win32::System::Threading::MUTEX_ALL_ACCESS;
7570
use windows_sys::Win32::System::Threading::OpenMutexW;
76-
use windows_sys::Win32::System::Threading::PROCESS_INFORMATION;
77-
use windows_sys::Win32::System::Threading::TerminateProcess;
7871
use windows_sys::Win32::System::Threading::WaitForSingleObject;
7972

8073
const READ_ACL_MUTEX_NAME: &str = "Local\\CodexSandboxReadAcl";
8174
const WAIT_TIMEOUT: u32 = 0x0000_0102;
8275

76+
/// Terminates the runner's child job when the parent control transport closes or requests it.
77+
///
78+
/// Production uses the shared kill-on-close job controller. Tests implement this boundary to
79+
/// verify that transport failure is fail-closed without launching a real sandbox account.
80+
trait RunnerJobController: Send + 'static {
81+
fn terminate_job(&self);
82+
}
83+
84+
impl RunnerJobController for KillOnCloseJob {
85+
fn terminate_job(&self) {
86+
let _ = self.terminate_and_close(1);
87+
}
88+
}
89+
8390
struct IpcSpawnedProcess {
8491
log_dir: PathBuf,
85-
pi: PROCESS_INFORMATION,
92+
process: JobProcess,
8693
stdout_handle: HANDLE,
8794
stderr_handle: HANDLE,
8895
stdin_handle: Option<HANDLE>,
8996
conpty_owner: Option<codex_windows_sandbox::ConptyInstance>,
9097
hpc_handle: Option<HANDLE>,
91-
_pipe_handles: Option<PipeSpawnHandles>,
98+
_pipe_desktop: Option<codex_windows_sandbox::LaunchDesktop>,
9299
}
93100

94101
/// Small RAII wrapper for raw Win32 handles.
95102
///
96-
/// The elevated runner has a few early-return paths where we acquire a token, job, or pipe
103+
/// The elevated runner has a few early-return paths where we acquire a token or pipe
97104
/// handle and then may fail while preparing the child. Keeping those handles in a guard makes
98105
/// the error paths read more directly and closes the gaps that were previously leaking them.
99106
struct OwnedWinHandle(HANDLE);
@@ -126,25 +133,6 @@ impl Drop for OwnedWinHandle {
126133
}
127134
}
128135

129-
unsafe fn create_job_kill_on_close() -> Result<HANDLE> {
130-
let h_job = OwnedWinHandle::new(CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()));
131-
if h_job.raw() == 0 {
132-
return Err(anyhow::anyhow!("CreateJobObjectW failed"));
133-
}
134-
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
135-
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
136-
let ok = SetInformationJobObject(
137-
h_job.raw(),
138-
JobObjectExtendedLimitInformation,
139-
&mut limits as *mut _ as *mut _,
140-
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
141-
);
142-
if ok == 0 {
143-
return Err(anyhow::anyhow!("SetInformationJobObject failed"));
144-
}
145-
Ok(h_job.into_raw())
146-
}
147-
148136
/// Open a named pipe created by the parent process.
149137
fn open_pipe(name: &str, access: u32) -> Result<HANDLE> {
150138
let path = to_wide(name);
@@ -298,9 +286,9 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result<IpcSpawnedProcess> {
298286

299287
let mut conpty_owner = None;
300288
let mut hpc_handle: Option<HANDLE> = None;
301-
let mut pipe_handles = None;
302-
let (pi, stdout_handle, stderr_handle, stdin_handle) = if req.tty {
303-
let (pi, mut conpty) = codex_windows_sandbox::spawn_conpty_process_as_user(
289+
let mut pipe_desktop = None;
290+
let (process, stdout_handle, stderr_handle, stdin_handle) = if req.tty {
291+
let (process, mut conpty) = codex_windows_sandbox::spawn_job_conpty_process_as_user(
304292
h_token.raw(),
305293
&req.command,
306294
&effective_cwd,
@@ -321,7 +309,7 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result<IpcSpawnedProcess> {
321309
None
322310
};
323311
(
324-
pi,
312+
process,
325313
output_read,
326314
windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE,
327315
stdin_handle,
@@ -332,7 +320,7 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result<IpcSpawnedProcess> {
332320
} else {
333321
StdinMode::Closed
334322
};
335-
let spawned_pipes: PipeSpawnHandles = spawn_process_with_pipes(
323+
let spawned_pipes: JobPipeSpawnHandles = spawn_job_process_with_pipes(
336324
h_token.raw(),
337325
&req.command,
338326
&effective_cwd,
@@ -342,24 +330,27 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result<IpcSpawnedProcess> {
342330
req.use_private_desktop,
343331
Some(log_dir.as_path()),
344332
)?;
345-
let pi = spawned_pipes.process;
346-
let stdout_handle = spawned_pipes.stdout_read;
347-
let stderr_handle = spawned_pipes
348-
.stderr_read
349-
.unwrap_or(windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE);
350-
let stdin_handle = spawned_pipes.stdin_write;
351-
pipe_handles = Some(spawned_pipes);
352-
(pi, stdout_handle, stderr_handle, stdin_handle)
333+
let JobPipeSpawnHandles {
334+
process,
335+
stdin_write: stdin_handle,
336+
stdout_read: stdout_handle,
337+
stderr_read: stderr_handle,
338+
desktop,
339+
} = spawned_pipes;
340+
let stderr_handle =
341+
stderr_handle.unwrap_or(windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE);
342+
pipe_desktop = Some(desktop);
343+
(process, stdout_handle, stderr_handle, stdin_handle)
353344
};
354345
Ok(IpcSpawnedProcess {
355346
log_dir,
356-
pi,
347+
process,
357348
stdout_handle,
358349
stderr_handle,
359350
stdin_handle,
360351
conpty_owner,
361352
hpc_handle,
362-
_pipe_handles: pipe_handles,
353+
_pipe_desktop: pipe_desktop,
363354
})
364355
}
365356

@@ -396,16 +387,30 @@ fn spawn_input_loop(
396387
mut reader: File,
397388
stdin_handle: Option<HANDLE>,
398389
hpc_handle: Arc<StdMutex<Option<HANDLE>>>,
399-
process_handle: Arc<StdMutex<Option<HANDLE>>>,
390+
job: impl RunnerJobController,
400391
log_dir: Option<PathBuf>,
401392
) -> std::thread::JoinHandle<()> {
402393
std::thread::spawn(move || {
403394
let mut stdin_handle = stdin_handle;
404395
loop {
405396
let msg = match read_frame(&mut reader) {
406397
Ok(Some(v)) => v,
407-
Ok(None) => break,
408-
Err(_) => break,
398+
Ok(None) => {
399+
log_note(
400+
"runner control pipe closed; terminating child job",
401+
log_dir.as_deref(),
402+
);
403+
job.terminate_job();
404+
break;
405+
}
406+
Err(err) => {
407+
log_note(
408+
&format!("runner control pipe read failed: {err}; terminating child job"),
409+
log_dir.as_deref(),
410+
);
411+
job.terminate_job();
412+
break;
413+
}
409414
};
410415
match msg.message {
411416
Message::Stdin { payload } => {
@@ -489,13 +494,7 @@ fn spawn_input_loop(
489494
}
490495
}
491496
Message::Terminate { .. } => {
492-
if let Ok(guard) = process_handle.lock()
493-
&& let Some(handle) = guard.as_ref()
494-
{
495-
unsafe {
496-
let _ = TerminateProcess(*handle, 1);
497-
}
498-
}
497+
job.terminate_job();
499498
}
500499
Message::SpawnRequest { .. } => {}
501500
Message::SpawnReady { .. } => {}
@@ -567,27 +566,21 @@ pub fn main() -> Result<()> {
567566
}
568567
};
569568
let log_dir = Some(ipc_spawn.log_dir.as_path());
570-
let pi = ipc_spawn.pi;
569+
let process = ipc_spawn.process;
570+
let process_handle = process.as_raw_handle() as HANDLE;
571+
let job = process.controller();
571572
let stdout_handle = ipc_spawn.stdout_handle;
572573
let stderr_handle = ipc_spawn.stderr_handle;
573574
let mut conpty_owner = ipc_spawn.conpty_owner;
575+
let pipe_desktop = ipc_spawn._pipe_desktop;
574576
let stdin_handle = ipc_spawn.stdin_handle;
575577
let hpc_handle = Arc::new(StdMutex::new(ipc_spawn.hpc_handle));
576578

577-
let h_job = unsafe { create_job_kill_on_close().ok() };
578-
if let Some(job) = h_job {
579-
unsafe {
580-
let _ = AssignProcessToJobObject(job, pi.hProcess);
581-
}
582-
}
583-
584-
let process_handle = Arc::new(StdMutex::new(Some(pi.hProcess)));
585-
586579
let msg = FramedMessage {
587580
version: IPC_PROTOCOL_VERSION,
588581
message: Message::SpawnReady {
589582
payload: SpawnReady {
590-
process_id: unsafe { GetProcessId(pi.hProcess) },
583+
process_id: process.process_id(),
591584
},
592585
},
593586
};
@@ -626,39 +619,34 @@ pub fn main() -> Result<()> {
626619
pipe_read,
627620
stdin_handle,
628621
Arc::clone(&hpc_handle),
629-
Arc::clone(&process_handle),
622+
job.clone(),
630623
log_dir_owned,
631624
);
632625

633626
let timeout = req.timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE);
634-
let wait_res = unsafe { WaitForSingleObject(pi.hProcess, timeout) };
627+
let wait_res = unsafe { WaitForSingleObject(process_handle, timeout) };
635628
let timed_out = wait_res == WAIT_TIMEOUT;
636629

637630
let exit_code: i32;
638631
unsafe {
639632
if timed_out {
640-
let _ = TerminateProcess(pi.hProcess, 1);
633+
let _ = job.terminate_and_close(1);
641634
exit_code = 128 + 64;
642635
} else {
643636
let mut raw_exit: u32 = 1;
644-
GetExitCodeProcess(pi.hProcess, &mut raw_exit);
637+
GetExitCodeProcess(process_handle, &mut raw_exit);
645638
exit_code = raw_exit as i32;
646-
}
647-
if pi.hThread != 0 {
648-
CloseHandle(pi.hThread);
649-
}
650-
if pi.hProcess != 0 {
651-
CloseHandle(pi.hProcess);
652-
}
653-
if let Some(job) = h_job {
654-
CloseHandle(job);
639+
// Root exit ends the session. Closing the job before joining readers ensures any
640+
// surviving descendants are stopped and release inherited output handles.
641+
let _ = job.close();
655642
}
656643
}
657644

658645
if let Ok(mut guard) = hpc_handle.lock() {
659646
let _ = guard.take();
660647
}
661648
drop(conpty_owner.take());
649+
drop(pipe_desktop);
662650

663651
let _ = out_thread.join();
664652
if let Some(thread) = err_thread {
@@ -682,3 +670,7 @@ pub fn main() -> Result<()> {
682670

683671
std::process::exit(exit_code);
684672
}
673+
674+
#[cfg(test)]
675+
#[path = "win_tests.rs"]
676+
mod tests;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use super::RunnerJobController;
2+
use super::spawn_input_loop;
3+
use std::sync::Arc;
4+
use std::sync::Mutex;
5+
use std::sync::atomic::AtomicBool;
6+
use std::sync::atomic::Ordering;
7+
8+
struct RecordingJobController {
9+
terminated: Arc<AtomicBool>,
10+
}
11+
12+
impl RunnerJobController for RecordingJobController {
13+
fn terminate_job(&self) {
14+
self.terminated.store(true, Ordering::SeqCst);
15+
}
16+
}
17+
18+
#[test]
19+
fn control_pipe_eof_terminates_child_job() {
20+
let terminated = Arc::new(AtomicBool::new(false));
21+
let input_thread = spawn_input_loop(
22+
tempfile::tempfile().expect("create empty control-pipe stand-in"),
23+
/*stdin_handle*/ None,
24+
Arc::new(Mutex::new(None)),
25+
RecordingJobController {
26+
terminated: Arc::clone(&terminated),
27+
},
28+
/*log_dir*/ None,
29+
);
30+
31+
input_thread.join().expect("join runner input loop");
32+
assert!(
33+
terminated.load(Ordering::SeqCst),
34+
"EOF from the parent transport must terminate the child job"
35+
);
36+
}

0 commit comments

Comments
 (0)