Compare commits

...

19 Commits

Author SHA1 Message Date
iceweasel-oai
1ec4660aa4 Fix elevated spawn prep after main rebase 2026-04-10 13:17:20 -07:00
iceweasel-oai
9fe99683e4 Fix PR3 CI lints 2026-04-10 13:13:09 -07:00
iceweasel-oai
ffd4d16b86 Fix Windows sandbox clippy lints 2026-04-10 13:13:09 -07:00
iceweasel-oai
103a507ffc Fix ExecRequest policy cwd plumbing 2026-04-10 13:13:08 -07:00
iceweasel-oai
a398487141 Fix Windows sandbox rebase fallout 2026-04-10 13:13:08 -07:00
iceweasel-oai
5c833bb30c address review comments. 2026-04-10 13:13:08 -07:00
iceweasel-oai
5422dfaf9e centralize sandbox policy/cwd in ExecRequest 2026-04-10 13:13:08 -07:00
iceweasel-oai
6557660306 Refactor Windows sandbox unified exec sessions 2026-04-10 13:13:08 -07:00
iceweasel-oai
fdaf7f9991 review comments 2026-04-10 13:13:08 -07:00
iceweasel-oai
71903e170d review comments 2026-04-10 13:13:08 -07:00
iceweasel-oai
178120abd7 review comments 2026-04-10 13:13:07 -07:00
iceweasel-oai
311189a6e0 fix compile issues 2026-04-10 13:13:07 -07:00
iceweasel-oai
d8c74e976d fix escaping for commands with spaces, and newline boundary issue 2026-04-10 13:13:07 -07:00
iceweasel-oai
1b3ed241c2 do not prematurely drop desktop handle 2026-04-10 13:13:07 -07:00
iceweasel-oai
e19cc77798 do not leak desktops 2026-04-10 13:13:07 -07:00
iceweasel-oai
c971d63857 review fixes 2026-04-10 13:13:07 -07:00
iceweasel-oai
3333fa1ec1 Fix Windows sandbox session formatting and syntax 2026-04-10 13:13:07 -07:00
iceweasel-oai
fc4914f50b Drop temporary Windows spawn matrix doc 2026-04-10 13:13:07 -07:00
iceweasel-oai
e1e0db82de Add Windows sandbox unified exec runtime support 2026-04-10 13:13:07 -07:00
30 changed files with 2592 additions and 208 deletions

View File

@@ -311,7 +311,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,
@@ -353,6 +357,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,

View File

@@ -42,6 +42,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,
@@ -68,6 +69,7 @@ impl ExecRequest {
network_sandbox_policy: NetworkSandboxPolicy,
arg0: Option<String>,
) -> Self {
let windows_sandbox_policy_cwd = cwd.clone();
Self {
command,
cwd,
@@ -76,6 +78,7 @@ impl ExecRequest {
expiration,
capture_policy,
sandbox,
windows_sandbox_policy_cwd,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy,
@@ -89,6 +92,7 @@ impl ExecRequest {
pub(crate) fn from_sandbox_exec_request(
request: SandboxExecRequest,
options: ExecOptions,
windows_sandbox_policy_cwd: AbsolutePathBuf,
) -> Self {
let SandboxExecRequest {
command,
@@ -125,6 +129,7 @@ impl ExecRequest {
expiration,
capture_policy,
sandbox,
windows_sandbox_policy_cwd,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy,

View File

@@ -168,6 +168,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

View File

@@ -1077,7 +1077,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}"))?;

View File

@@ -128,6 +128,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,
@@ -155,7 +156,7 @@ pub(super) async fn try_run_zsh_fork(
network: sandbox_network,
windows_sandbox_level,
arg0,
sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(),
sandbox_policy_cwd,
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
};
@@ -253,7 +254,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
network: exec_request.network.clone(),
windows_sandbox_level: exec_request.windows_sandbox_level,
arg0: exec_request.arg0.clone(),
sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(),
sandbox_policy_cwd: ctx.turn.cwd.clone(),
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
};
@@ -674,7 +675,7 @@ struct CoreShellCommandExecutor {
network: Option<codex_network_proxy::NetworkProxy>,
windows_sandbox_level: WindowsSandboxLevel,
arg0: Option<String>,
sandbox_policy_cwd: PathBuf,
sandbox_policy_cwd: AbsolutePathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
use_legacy_landlock: bool,
}
@@ -717,6 +718,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(),
@@ -856,8 +858,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);
}

View File

@@ -356,7 +356,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,
)
})
}
}

View File

@@ -593,6 +593,60 @@ impl UnifiedExecProcessManager {
.ok_or(UnifiedExecError::MissingCommandLine)?;
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(

View File

@@ -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.

View File

@@ -235,6 +235,7 @@ async fn spawn_process_with_stdin_mode(
exit_status,
exit_code,
/*pty_handles*/ None,
/*resizer*/ None,
);
Ok(SpawnedProcess {

View File

@@ -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<dyn FnMut(TerminalSize) -> anyhow::Result<()> + Send>;
/// Handle for driving an interactive process (PTY or pipe).
pub struct ProcessHandle {
writer_tx: StdMutex<Option<mpsc::Sender<Vec<u8>>>>,
@@ -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<Option<PtyHandles>>,
// Optional resize hook for driver-backed sessions that proxy PTY control to
// another backend instead of owning local PTY handles.
resizer: StdMutex<Option<ResizeFn>>,
}
impl fmt::Debug for ProcessHandle {
@@ -102,6 +110,7 @@ impl ProcessHandle {
exit_status: Arc<AtomicBool>,
exit_code: Arc<StdMutex<Option<i32>>>,
pty_handles: Option<PtyHandles>,
resizer: Option<ResizeFn>,
) -> 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<Box<dyn FnMut() + Send + Sync>>,
}
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<Vec<u8>>,
pub exit_rx: oneshot::Receiver<i32>,
}
/// Driver-backed process handles for non-standard spawn backends.
pub struct ProcessDriver {
pub writer_tx: mpsc::Sender<Vec<u8>>,
pub stdout_rx: broadcast::Receiver<Vec<u8>>,
pub stderr_rx: Option<broadcast::Receiver<Vec<u8>>>,
pub exit_rx: oneshot::Receiver<i32>,
pub terminator: Option<Box<dyn FnMut() + Send + Sync>>,
pub writer_handle: Option<JoinHandle<()>>,
pub resizer: Option<ResizeFn>,
}
/// 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::<Vec<u8>>(256);
let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(256);
let (exit_seen_tx, exit_seen_rx) = watch::channel(false);
let spawn_stream_reader =
|mut output_rx: broadcast::Receiver<Vec<u8>>,
output_tx: mpsc::Sender<Vec<u8>>,
mut exit_seen_rx: watch::Receiver<bool>| {
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::<i32>();
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,
}
}

View File

@@ -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 {

View File

@@ -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::<Vec<u8>>(1);
let (stdout_tx, stdout_driver_rx) = tokio::sync::broadcast::channel::<Vec<u8>>(8);
let (stderr_tx, stderr_driver_rx) = tokio::sync::broadcast::channel::<Vec<u8>>(8);
let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<i32>();
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::<Vec<u8>>(1);
let (_stdout_tx, stdout_driver_rx) = tokio::sync::broadcast::channel::<Vec<u8>>(8);
let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<i32>();
let (size_tx, size_rx) = tokio::sync::oneshot::channel::<TerminalSize>();
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() {

View File

@@ -118,6 +118,10 @@ fn windows_build_number() -> Option<u32> {
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> {

View File

@@ -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<LaunchDesktop>,
}
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<LaunchDesktop>) {
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<ConptyInstance> {
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<ConptyInstance> {
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))
}

View File

@@ -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<HANDLE>,
hpc_handle: Option<HANDLE>,
_desktop_owner: Option<LaunchDesktop>,
_pipe_handles: Option<PipeSpawnHandles>,
}
unsafe fn create_job_kill_on_close() -> Result<HANDLE> {
@@ -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<IpcSpawnedProcess> {
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<IpcSpawnedProcess> {
}
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<HANDLE> = 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<IpcSpawnedProcess> {
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<IpcSpawnedProcess> {
} 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<IpcSpawnedProcess> {
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<HANDLE>,
hpc_handle: Arc<StdMutex<Option<HANDLE>>>,
process_handle: Arc<StdMutex<Option<HANDLE>>>,
log_dir: Option<PathBuf>,
_log_dir: Option<PathBuf>,
) -> 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 {

View File

@@ -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 {

View File

@@ -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<RunnerTransport> {
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::<STARTUPINFOW>() as u32;
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
let env_block: Option<Vec<u16>> = 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);
}
}

View File

@@ -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::<u128>());
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

View File

@@ -32,6 +32,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;
@@ -43,9 +47,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;
@@ -70,6 +94,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;
@@ -100,6 +126,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;
@@ -134,6 +162,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;
@@ -215,16 +247,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;
@@ -249,15 +278,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<PipeHandles> {
let mut in_r: HANDLE = 0;
let mut in_w: HANDLE = 0;
@@ -329,27 +349,19 @@ mod windows_impl {
additional_deny_write_paths: &[PathBuf],
use_private_desktop: bool,
) -> Result<CaptureResult> {
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"
@@ -445,7 +457,6 @@ mod windows_impl {
let _ = protect_workspace_agents_dir(&current_dir, 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 {
@@ -576,7 +587,6 @@ mod windows_impl {
}
}
}
Ok(CaptureResult {
exit_code,
stdout,
@@ -610,7 +620,6 @@ mod windows_impl {
let AllowDenyPaths { allow, deny } =
compute_allow_paths(sandbox_policy, sandbox_policy_cwd, &current_dir, env_map);
let canonical_cwd = canonicalize_path(&current_dir);
unsafe {
for p in &allow {
let psid = if is_command_cwd_root(p, &canonical_cwd) {
@@ -634,8 +643,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 {

View File

@@ -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 ConPTYspecific and used in both legacy and
//! elevated unified_exec paths when spawning a PTYbacked 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<u8>,
handle_list: Option<Vec<HANDLE>>,
}
impl ProcThreadAttributeList {
/// Allocate and initialize a thread attribute list.
pub fn new(attr_count: u32) -> io::Result<Self> {
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<HANDLE>) -> 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 {

View File

@@ -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<CreatedProcess> {
let cmdline_str = argv
.iter()
.map(|a| quote_windows_arg(a))
.collect::<Vec<_>>()
.join(" ");
let cmdline_str = argv_to_command_line(argv);
let mut cmdline: Vec<u16> = 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::<STARTUPINFOW>() 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::<STARTUPINFOEXW>() 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::<STARTUPINFOW>() 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<HANDLE>,
pub stdout_read: HANDLE,
pub stderr_read: Option<HANDLE>,
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<PipeSpawnHandles> {
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,
})
}

View File

@@ -19,16 +19,16 @@ fn find_git_root(start: &Path) -> Option<PathBuf> {
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);
}

View File

@@ -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<PathBuf>,
pub(crate) is_workspace_write: bool,
}
pub(crate) struct ElevatedSpawnContext {
pub(crate) common: SpawnContext,
pub(crate) sandbox_creds: SandboxCreds,
pub(crate) cap_sids: Vec<String>,
}
pub(crate) struct LegacySessionSecurity {
pub(crate) h_token: HANDLE,
pub(crate) psid_generic: LocalSid,
pub(crate) psid_workspace: Option<LocalSid>,
pub(crate) cap_sid_str: String,
}
pub(crate) struct LocalSid {
psid: *mut c_void,
}
impl LocalSid {
pub(crate) fn from_string(sid: &str) -> Result<Self> {
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<String, String>,
command: &[String],
inherit_path: bool,
add_git_safe_directory: bool,
) -> Result<SpawnContext> {
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<LegacySessionSecurity> {
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<String, String>,
psid_generic: &LocalSid,
psid_workspace: Option<&LocalSid>,
persist_aces: bool,
) -> Vec<PathBuf> {
let AllowDenyPaths { allow, deny } =
compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map);
let mut guards: Vec<PathBuf> = 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<String, String>,
command: &[String],
) -> Result<ElevatedSpawnContext> {
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<PathBuf> = allow.into_iter().collect();
let deny_write_paths: Vec<PathBuf> = 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,
})
}

View File

@@ -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<String>,
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
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::<Vec<u8>>(128);
let (stdout_tx, stdout_rx) = broadcast::channel::<Vec<u8>>(256);
let stderr_rx = if tty {
None
} else {
Some(broadcast::channel::<Vec<u8>>(256))
};
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
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<dyn FnMut() + Send + Sync>)
};
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,
))
}

View File

@@ -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<HANDLE>,
token_handle: HANDLE,
desktop: Option<LaunchDesktop>,
}
#[allow(clippy::too_many_arguments)]
fn spawn_legacy_process(
h_token: HANDLE,
command: &[String],
cwd: &Path,
env_map: &HashMap<String, String>,
use_private_desktop: bool,
tty: bool,
stdin_open: bool,
stdout_tx: broadcast::Sender<Vec<u8>>,
stderr_tx: Option<broadcast::Sender<Vec<u8>>>,
writer_rx: mpsc::Receiver<Vec<u8>>,
logs_base_dir: Option<&Path>,
) -> Result<LegacyProcessHandles> {
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<Vec<u8>>,
) -> std::thread::JoinHandle<()> {
read_handle_loop(output_read, move |chunk| {
let _ = output_tx.send(chunk.to_vec());
})
}
fn spawn_input_writer(
input_write: Option<HANDLE>,
mut writer_rx: mpsc::Receiver<Vec<u8>>,
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<i32>,
process_handle: Arc<StdMutex<Option<HANDLE>>>,
thread_handle: HANDLE,
output_join: std::thread::JoinHandle<()>,
guards: Vec<PathBuf>,
cap_sid: Option<String>,
logs_base_dir: Option<&Path>,
command: Vec<String>,
) {
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<StdMutex<Option<HANDLE>>>, 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<String>,
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
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::<Vec<u8>>(128);
let (stdout_tx, stdout_rx) = broadcast::channel::<Vec<u8>>(256);
let stderr_rx = if tty {
None
} else {
Some(broadcast::channel::<Vec<u8>>(256))
};
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
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<dyn FnMut() + Send + Sync>)
};
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<dyn FnMut(TerminalSize) -> Result<()> + Send>
}),
};
Ok(finish_driver_spawn(driver, stdin_open))
}

View File

@@ -0,0 +1,3 @@
pub(crate) mod elevated;
pub(crate) mod legacy;
pub(crate) mod windows_common;

View File

@@ -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<u8> {
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<FramedMessage> {
let (outbound_tx, outbound_rx) = std::sync::mpsc::channel::<FramedMessage>();
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<Vec<u8>>,
outbound_tx: std::sync::mpsc::Sender<FramedMessage>,
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<Vec<u8>>,
stderr_tx: Option<broadcast::Sender<Vec<u8>>>,
exit_tx: oneshot::Sender<i32>,
) {
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<FramedMessage>,
) -> Box<dyn FnMut(TerminalSize) -> 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<Vec<u8>>,
stderr_tx: Option<&broadcast::Sender<Vec<u8>>>,
) {
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);
}
}

View File

@@ -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<String>,
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
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<String>,
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
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;

View File

@@ -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<PathBuf> {
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<Message> {
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<u8>, 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::<Vec<u8>>(1);
let (_stdout_tx, stdout_rx) = broadcast::channel::<Vec<u8>>(1);
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
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::<Vec<u8>>(1);
let (_stdout_tx, stdout_rx) = broadcast::channel::<Vec<u8>>(1);
let (exit_tx, exit_rx) = oneshot::channel::<i32>();
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::<Vec<u8>>(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:?}");
});
}

View File

@@ -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::<Vec<_>>()
.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<Vec<u8>> {
}
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\\\"\""
);
}
}