Compare commits

...

10 Commits

Author SHA1 Message Date
iceweasel-oai
aac69fd5ba Enable Windows sandbox command/exec in app-server 2026-03-23 16:45:32 -07:00
iceweasel-oai
969be7c0ad fix compile issues 2026-03-23 15:57:14 -07:00
iceweasel-oai
3a95229ee9 fix escaping for commands with spaces, and newline boundary issue 2026-03-23 15:47:13 -07:00
iceweasel-oai
5044e826ab do not prematurely drop desktop handle 2026-03-23 15:32:55 -07:00
iceweasel-oai
4455a9259e do not leak desktops 2026-03-23 15:19:44 -07:00
iceweasel-oai
717c20f74a review fixes 2026-03-23 15:07:14 -07:00
iceweasel-oai
72ead59eaa Fix Windows sandbox session formatting and syntax 2026-03-23 14:52:20 -07:00
iceweasel-oai
427100f11c Restore Windows unified exec advertising gate 2026-03-23 14:43:42 -07:00
iceweasel-oai
e68a0b0ded Drop temporary Windows spawn matrix doc 2026-03-23 14:24:26 -07:00
iceweasel-oai
8f69761b1e Add Windows sandbox unified exec runtime support 2026-03-23 14:21:08 -07:00
28 changed files with 3049 additions and 654 deletions

View File

@@ -665,8 +665,7 @@ Notes:
- `disableTimeout: true` disables the timeout entirely for that `command/exec` request. It cannot be combined with `timeoutMs`.
- `processId` is optional for buffered execution. When omitted, Codex generates an internal id for lifecycle tracking, but `tty`, `streamStdin`, and `streamStdoutStderr` must stay disabled and follow-up `command/exec/write` / `command/exec/terminate` calls are not available for that command.
- `size` is only valid when `tty: true`. It sets the initial PTY size in character cells.
- Buffered Windows sandbox execution accepts `processId` for correlation, but `command/exec/write` and `command/exec/terminate` are still unsupported for those requests.
- Buffered Windows sandbox execution also requires the default output cap; custom `outputBytesCap` and `disableOutputCap` are unsupported there.
- Windows sandbox execution supports client-supplied `processId` values for buffered and streaming requests, including follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls when the original request enabled streaming.
- `tty`, `streamStdin`, and `streamStdoutStderr` are optional booleans. Legacy requests that omit them continue to use buffered execution.
- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`.
- `tty` and `streamStdin` do not disable the timeout on their own; omit `timeoutMs` to use the server default timeout, or set `disableTimeout: true` to keep the process alive until exit or explicit termination.

View File

@@ -1745,6 +1745,7 @@ impl CodexMessageProcessor {
request_id: request_for_task,
process_id,
exec_request,
sandbox_policy_cwd: sandbox_cwd,
started_network_proxy: started_network_proxy_for_task,
tty,
stream_stdin,

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
@@ -25,6 +26,7 @@ use codex_core::exec::ExecExpiration;
use codex_core::exec::IO_DRAIN_TIMEOUT_MS;
use codex_core::exec::SandboxType;
use codex_core::sandboxing::ExecRequest;
#[cfg(all(test, not(target_os = "windows")))]
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use codex_utils_pty::ProcessHandle;
use codex_utils_pty::SpawnedProcess;
@@ -69,7 +71,6 @@ enum CommandExecSession {
Active {
control_tx: mpsc::Sender<CommandControlRequest>,
},
UnsupportedWindowsSandbox,
}
enum CommandControl {
@@ -88,6 +89,7 @@ pub(crate) struct StartCommandExecParams {
pub(crate) request_id: ConnectionRequestId,
pub(crate) process_id: Option<String>,
pub(crate) exec_request: ExecRequest,
pub(crate) sandbox_policy_cwd: PathBuf,
pub(crate) started_network_proxy: Option<StartedNetworkProxy>,
pub(crate) tty: bool,
pub(crate) stream_stdin: bool,
@@ -148,6 +150,7 @@ impl CommandExecManager {
request_id,
process_id,
exec_request,
sandbox_policy_cwd,
started_network_proxy,
tty,
stream_stdin,
@@ -174,71 +177,9 @@ impl CommandExecManager {
process_id: process_id.clone(),
};
if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) {
if tty || stream_stdin || stream_stdout_stderr {
return Err(invalid_request(
"streaming command/exec is not supported with windows sandbox".to_string(),
));
}
if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) {
return Err(invalid_request(
"custom outputBytesCap is not supported with windows sandbox".to_string(),
));
}
if let InternalProcessId::Client(_) = &process_id {
let mut sessions = self.sessions.lock().await;
if sessions.contains_key(&process_key) {
return Err(invalid_request(format!(
"duplicate active command/exec process id: {}",
process_key.process_id.error_repr(),
)));
}
sessions.insert(
process_key.clone(),
CommandExecSession::UnsupportedWindowsSandbox,
);
}
let sessions = Arc::clone(&self.sessions);
tokio::spawn(async move {
let _started_network_proxy = started_network_proxy;
match codex_core::sandboxing::execute_env(exec_request, /*stdout_stream*/ None)
.await
{
Ok(output) => {
outgoing
.send_response(
request_id,
CommandExecResponse {
exit_code: output.exit_code,
stdout: output.stdout.text,
stderr: output.stderr.text,
},
)
.await;
}
Err(err) => {
outgoing
.send_error(request_id, internal_error(format!("exec failed: {err}")))
.await;
}
}
sessions.lock().await.remove(&process_key);
});
return Ok(());
}
let ExecRequest {
command,
cwd,
env,
expiration,
sandbox: _sandbox,
arg0,
..
} = exec_request;
let stream_stdin = tty || stream_stdin;
let stream_stdout_stderr = tty || stream_stdout_stderr;
let expiration = exec_request.expiration.clone();
let (control_tx, control_rx) = mpsc::channel(32);
let notification_process_id = match &process_id {
InternalProcessId::Generated(_) => None,
@@ -246,9 +187,6 @@ impl CommandExecManager {
};
let sessions = Arc::clone(&self.sessions);
let (program, args) = command
.split_first()
.ok_or_else(|| invalid_request("command must not be empty".to_string()))?;
{
let mut sessions = self.sessions.lock().await;
if sessions.contains_key(&process_key) {
@@ -262,29 +200,81 @@ impl CommandExecManager {
CommandExecSession::Active { control_tx },
);
}
let spawned = if tty {
codex_utils_pty::spawn_pty_process(
program,
args,
cwd.as_path(),
&env,
&arg0,
size.unwrap_or_default(),
)
.await
} else if stream_stdin {
codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await
} else {
codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0)
let windows_sandbox = matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken);
let spawned = if windows_sandbox {
#[cfg(target_os = "windows")]
{
codex_core::sandboxing::spawn_windows_sandbox_env(
exec_request,
sandbox_policy_cwd.as_path(),
tty,
stream_stdin,
)
.await
.map_err(|err| internal_error(format!("failed to spawn command: {err}")))
}
#[cfg(not(target_os = "windows"))]
{
Err(internal_error(
"windows sandbox command/exec is only supported on Windows".to_string(),
))
}
} else {
let ExecRequest {
command,
cwd,
env,
expiration: _expiration,
sandbox: _sandbox,
arg0,
..
} = &exec_request;
let (program, args) = command
.split_first()
.ok_or_else(|| invalid_request("command must not be empty".to_string()))?;
if tty {
codex_utils_pty::spawn_pty_process(
program,
args,
cwd.as_path(),
env,
arg0,
size.unwrap_or_default(),
)
.await
.map_err(|err| internal_error(format!("failed to spawn command: {err}")))
} else if stream_stdin {
codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), env, arg0)
.await
.map_err(|err| internal_error(format!("failed to spawn command: {err}")))
} else {
codex_utils_pty::spawn_pipe_process_no_stdin(
program,
args,
cwd.as_path(),
env,
arg0,
)
.await
.map_err(|err| internal_error(format!("failed to spawn command: {err}")))
}
};
let spawned = match spawned {
Ok(spawned) => spawned,
Err(err) => {
self.sessions.lock().await.remove(&process_key);
return Err(internal_error(format!("failed to spawn command: {err}")));
return Err(err);
}
};
if windows_sandbox
&& tty
&& let Some(size) = size
{
if let Err(err) = spawned.session.resize(size) {
self.sessions.lock().await.remove(&process_key);
return Err(internal_error(format!("failed to resize PTY: {err}")));
}
}
tokio::spawn(async move {
let _started_network_proxy = started_network_proxy;
run_command(RunCommandParams {
@@ -389,14 +379,13 @@ impl CommandExecManager {
};
for control in controls {
if let CommandExecSession::Active { control_tx } = control {
let _ = control_tx
.send(CommandControlRequest {
control: CommandControl::Terminate,
response_tx: None,
})
.await;
}
let CommandExecSession::Active { control_tx } = control;
let _ = control_tx
.send(CommandControlRequest {
control: CommandControl::Terminate,
response_tx: None,
})
.await;
}
}
@@ -418,11 +407,7 @@ impl CommandExecManager {
))
})?
};
let CommandExecSession::Active { control_tx } = session else {
return Err(invalid_request(
"command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes".to_string(),
));
};
let CommandExecSession::Active { control_tx } = session;
let (response_tx, response_rx) = oneshot::channel();
let request = CommandControlRequest {
control,
@@ -700,13 +685,10 @@ fn internal_error(message: String) -> JSONRPCErrorError {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
#[cfg(not(target_os = "windows"))]
use std::path::PathBuf;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::ReadOnlyAccess;
#[cfg(not(target_os = "windows"))]
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
#[cfg(not(target_os = "windows"))]
@@ -717,108 +699,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
#[cfg(not(target_os = "windows"))]
use crate::outgoing_message::OutgoingEnvelope;
#[cfg(not(target_os = "windows"))]
use crate::outgoing_message::OutgoingMessage;
fn windows_sandbox_exec_request() -> ExecRequest {
let sandbox_policy = SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
};
ExecRequest {
command: vec!["cmd".to_string()],
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
expiration: ExecExpiration::DefaultTimeout,
sandbox: SandboxType::WindowsRestrictedToken,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
justification: None,
arg0: None,
}
}
#[tokio::test]
async fn windows_sandbox_streaming_exec_is_rejected() {
let (tx, _rx) = mpsc::channel(1);
let manager = CommandExecManager::default();
let err = manager
.start(StartCommandExecParams {
outgoing: Arc::new(OutgoingMessageSender::new(tx)),
request_id: ConnectionRequestId {
connection_id: ConnectionId(1),
request_id: codex_app_server_protocol::RequestId::Integer(42),
},
process_id: Some("proc-42".to_string()),
exec_request: windows_sandbox_exec_request(),
started_network_proxy: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: None,
size: None,
})
.await
.expect_err("streaming windows sandbox exec should be rejected");
assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
err.message,
"streaming command/exec is not supported with windows sandbox"
);
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn windows_sandbox_non_streaming_exec_uses_execution_path() {
let (tx, mut rx) = mpsc::channel(1);
let manager = CommandExecManager::default();
let request_id = ConnectionRequestId {
connection_id: ConnectionId(7),
request_id: codex_app_server_protocol::RequestId::Integer(99),
};
manager
.start(StartCommandExecParams {
outgoing: Arc::new(OutgoingMessageSender::new(tx)),
request_id: request_id.clone(),
process_id: Some("proc-99".to_string()),
exec_request: windows_sandbox_exec_request(),
started_network_proxy: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP),
size: None,
})
.await
.expect("non-streaming windows sandbox exec should start");
let envelope = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("timed out waiting for outgoing message")
.expect("channel closed before outgoing message");
let OutgoingEnvelope::ToConnection {
connection_id,
message,
} = envelope
else {
panic!("expected connection-scoped outgoing message");
};
assert_eq!(connection_id, request_id.connection_id);
let OutgoingMessage::Error(error) = message else {
panic!("expected execution failure to be reported as an error");
};
assert_eq!(error.id, request_id.request_id);
assert!(error.error.message.starts_with("exec failed:"));
}
#[cfg(not(target_os = "windows"))]
#[tokio::test]
@@ -855,6 +735,7 @@ mod tests {
justification: None,
arg0: None,
},
sandbox_policy_cwd: PathBuf::from("."),
started_network_proxy: None,
tty: false,
stream_stdin: false,
@@ -906,76 +787,6 @@ mod tests {
// replying, so shell startup noise is allowed here.
}
#[tokio::test]
async fn windows_sandbox_process_ids_reject_write_requests() {
let manager = CommandExecManager::default();
let request_id = ConnectionRequestId {
connection_id: ConnectionId(11),
request_id: codex_app_server_protocol::RequestId::Integer(1),
};
let process_id = ConnectionProcessId {
connection_id: request_id.connection_id,
process_id: InternalProcessId::Client("proc-11".to_string()),
};
manager
.sessions
.lock()
.await
.insert(process_id, CommandExecSession::UnsupportedWindowsSandbox);
let err = manager
.write(
request_id,
CommandExecWriteParams {
process_id: "proc-11".to_string(),
delta_base64: Some(STANDARD.encode("hello")),
close_stdin: false,
},
)
.await
.expect_err("windows sandbox process ids should reject command/exec/write");
assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
err.message,
"command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes"
);
}
#[tokio::test]
async fn windows_sandbox_process_ids_reject_terminate_requests() {
let manager = CommandExecManager::default();
let request_id = ConnectionRequestId {
connection_id: ConnectionId(12),
request_id: codex_app_server_protocol::RequestId::Integer(2),
};
let process_id = ConnectionProcessId {
connection_id: request_id.connection_id,
process_id: InternalProcessId::Client("proc-12".to_string()),
};
manager
.sessions
.lock()
.await
.insert(process_id, CommandExecSession::UnsupportedWindowsSandbox);
let err = manager
.terminate(
request_id,
CommandExecTerminateParams {
process_id: "proc-12".to_string(),
},
)
.await
.expect_err("windows sandbox process ids should reject command/exec/terminate");
assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
err.message,
"command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes"
);
}
#[tokio::test]
async fn dropped_control_request_is_reported_as_not_running() {
let manager = CommandExecManager::default();

View File

@@ -95,11 +95,15 @@ pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
impl McpProcess {
pub async fn new(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, &[], &[]).await
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(program.as_path(), codex_home, &[], &[]).await
}
pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, &[], args).await
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(program.as_path(), codex_home, &[], args).await
}
/// Creates a new MCP process, allowing tests to override or remove
@@ -111,7 +115,9 @@ impl McpProcess {
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, env_overrides, &[]).await
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(program.as_path(), codex_home, env_overrides, &[]).await
}
pub async fn new_with_env_and_args(
@@ -121,6 +127,27 @@ impl McpProcess {
) -> anyhow::Result<Self> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(program.as_path(), codex_home, env_overrides, args)
.await
}
pub async fn new_codex_cli_with_args(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
args: &[&str],
) -> anyhow::Result<Self> {
let program =
codex_utils_cargo_bin::cargo_bin("codex").context("should find binary for codex")?;
Self::new_with_program_env_and_args(program.as_path(), codex_home, env_overrides, args)
.await
}
async fn new_with_program_env_and_args(
program: &Path,
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
args: &[&str],
) -> anyhow::Result<Self> {
let mut cmd = Command::new(program);
cmd.stdin(Stdio::piped());

View File

@@ -15,9 +15,19 @@ use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
#[cfg(target_os = "windows")]
use codex_app_server_protocol::ReadOnlyAccess;
use codex_app_server_protocol::RequestId;
#[cfg(target_os = "windows")]
use codex_app_server_protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[cfg(target_os = "windows")]
use std::fs::OpenOptions;
#[cfg(target_os = "windows")]
use std::io::Write;
#[cfg(target_os = "windows")]
use std::path::Path;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::Instant;
@@ -33,6 +43,28 @@ use super::connection_handling_websocket::send_initialize_request;
use super::connection_handling_websocket::send_request;
use super::connection_handling_websocket::spawn_websocket_server;
#[cfg(target_os = "windows")]
fn pwsh_path() -> Option<String> {
let program_files = std::env::var_os("ProgramFiles")?;
let path = std::path::PathBuf::from(program_files).join("PowerShell\\7\\pwsh.exe");
path.is_file().then(|| path.display().to_string())
}
#[cfg(target_os = "windows")]
fn create_config_toml_with_windows_sandbox_mode(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
windows_sandbox_mode: &str,
) -> std::io::Result<()> {
create_config_toml(codex_home, server_uri, approval_policy)?;
let mut file = OpenOptions::new()
.append(true)
.open(codex_home.join("config.toml"))?;
writeln!(file, "\n[windows]\nsandbox = \"{windows_sandbox_mode}\"")?;
Ok(())
}
#[tokio::test]
async fn command_exec_without_streams_can_be_terminated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
@@ -704,6 +736,403 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_tty_streams_and_accepts_input() -> Result<()> {
let Some(pwsh) = pwsh_path() else {
return Ok(());
};
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "windows-sandbox-tty-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
pwsh,
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NoExit".to_string(),
"-Command".to_string(),
"$PID; Write-Output ready".to_string(),
],
process_id: Some(process_id.clone()),
tty: true,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
})
.await?;
let started_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"ready",
)
.await?;
assert!(
started_text.contains("ready"),
"unexpected output: {started_text:?}"
);
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id: process_id.clone(),
delta_base64: Some(STANDARD.encode("Write-Output second\r\n")),
close_stdin: false,
})
.await?;
let write_response = mcp
.read_stream_until_response_message(RequestId::Integer(write_request_id))
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let second_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"second",
)
.await?;
assert!(
second_text.contains("second"),
"unexpected output: {second_text:?}"
);
let exit_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id,
delta_base64: Some(STANDARD.encode("exit\r\n")),
close_stdin: false,
})
.await?;
let exit_response = mcp
.read_stream_until_response_message(RequestId::Integer(exit_request_id))
.await?;
assert_eq!(exit_response.result, serde_json::json!({}));
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(response.exit_code, 0);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_tty_supports_initial_size_and_resize_unelevated() -> Result<()>
{
let Some(pwsh) = pwsh_path() else {
return Ok(());
};
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml_with_windows_sandbox_mode(
codex_home.path(),
&server.uri(),
"never",
"unelevated",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "windows-sandbox-tty-size-unelevated-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
pwsh,
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"$size=$Host.UI.RawUI.WindowSize; Write-Output ('start:{0} {1}' -f $size.Height, $size.Width); $null=Read-Host; $size=$Host.UI.RawUI.WindowSize; Write-Output ('after:{0} {1}' -f $size.Height, $size.Width)".to_string(),
],
process_id: Some(process_id.clone()),
tty: true,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: Some(CommandExecTerminalSize {
rows: 31,
cols: 101,
}),
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
})
.await?;
let started_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"start:31 101",
)
.await?;
assert!(
started_text.contains("start:31 101"),
"unexpected initial size output: {started_text:?}"
);
let resize_request_id = mcp
.send_command_exec_resize_request(CommandExecResizeParams {
process_id: process_id.clone(),
size: CommandExecTerminalSize {
rows: 45,
cols: 132,
},
})
.await?;
let resize_response = mcp
.read_stream_until_response_message(RequestId::Integer(resize_request_id))
.await?;
assert_eq!(resize_response.result, serde_json::json!({}));
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id: process_id.clone(),
delta_base64: Some(STANDARD.encode("go\r\n")),
close_stdin: true,
})
.await?;
let write_response = mcp
.read_stream_until_response_message(RequestId::Integer(write_request_id))
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let resized_text = read_command_exec_output_until_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"after:45 132",
)
.await?;
assert!(
resized_text.contains("after:45 132"),
"unexpected resized output: {resized_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(response.exit_code, 0);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_pipe_streams_input_without_tty() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
assert_windows_sandbox_pipe_streams_input_without_tty(&mut mcp, "windows-sandbox-pipe-1")
.await?;
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_pipe_streams_input_without_tty_via_codex_cli() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp =
McpProcess::new_codex_cli_with_args(codex_home.path(), &[], &["app-server"]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
assert_windows_sandbox_pipe_streams_input_without_tty(&mut mcp, "windows-sandbox-pipe-cli-1")
.await?;
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_pipe_streams_input_without_tty_unelevated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml_with_windows_sandbox_mode(
codex_home.path(),
&server.uri(),
"never",
"unelevated",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
assert_windows_sandbox_pipe_streams_input_without_tty(
&mut mcp,
"windows-sandbox-pipe-unelevated-1",
)
.await?;
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_pipe_preserves_stderr_unelevated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml_with_windows_sandbox_mode(
codex_home.path(),
&server.uri(),
"never",
"unelevated",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "windows-sandbox-pipe-stderr-unelevated-1".to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"C:\\Windows\\System32\\cmd.exe".to_string(),
"/c".to_string(),
"(echo split-out)&(>&2 echo split-err)".to_string(),
],
process_id: Some(process_id.clone()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
})
.await?;
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let mut stdout_text = String::new();
let mut stderr_text = String::new();
while !(stdout_text.contains("split-out") && stderr_text.contains("split-err")) {
let remaining = deadline.saturating_duration_since(Instant::now());
let delta = timeout(remaining, read_command_exec_delta(&mut mcp))
.await
.context("timed out waiting for split stdout/stderr notifications")??;
assert_eq!(delta.process_id, process_id);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
match delta.stream {
CommandExecOutputStream::Stdout => stdout_text.push_str(&delta_text.replace('\r', "")),
CommandExecOutputStream::Stderr => stderr_text.push_str(&delta_text.replace('\r', "")),
}
}
assert!(
stdout_text.contains("split-out"),
"unexpected stdout output: {stdout_text:?}"
);
assert!(
stderr_text.contains("split-err"),
"unexpected stderr output: {stderr_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(response.exit_code, 0);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
#[cfg(target_os = "windows")]
#[tokio::test]
async fn command_exec_windows_sandbox_rejects_restricted_read_only_unelevated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml_with_windows_sandbox_mode(
codex_home.path(),
&server.uri(),
"never",
"unelevated",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"C:\\Windows\\System32\\cmd.exe".to_string(),
"/c".to_string(),
"echo restricted".to_string(),
],
process_id: Some("windows-sandbox-restricted-unelevated-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![],
},
network_access: false,
}),
})
.await?;
let error = mcp
.read_stream_until_response_error(RequestId::Integer(request_id))
.await?;
assert!(
error
.error
.message
.contains("Restricted read-only access requires the elevated Windows sandbox backend"),
"unexpected error: {:?}",
error.error.message
);
Ok(())
}
#[tokio::test]
async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminates_process()
-> Result<()> {
@@ -815,7 +1244,9 @@ async fn read_command_exec_output_until_contains(
)
})??;
assert_eq!(delta.process_id, process_id);
assert_eq!(delta.stream, stream);
if delta.stream != stream {
continue;
}
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
collected.push_str(&delta_text.replace('\r', ""));
@@ -848,6 +1279,71 @@ fn decode_delta_notification(
serde_json::from_value(params).context("deserialize command/exec/outputDelta notification")
}
#[cfg(target_os = "windows")]
async fn assert_windows_sandbox_pipe_streams_input_without_tty(
mcp: &mut McpProcess,
process_id: &str,
) -> Result<()> {
let process_id = process_id.to_string();
let command_request_id = mcp
.send_command_exec_request(CommandExecParams {
command: vec![
"C:\\Windows\\System32\\cmd.exe".to_string(),
"/c".to_string(),
"findstr .".to_string(),
],
process_id: Some(process_id.clone()),
tty: false,
stream_stdin: true,
stream_stdout_stderr: true,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: Some(SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::FullAccess,
network_access: false,
}),
})
.await?;
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
process_id: process_id.clone(),
delta_base64: Some(STANDARD.encode("hello from stdin\r\n")),
close_stdin: true,
})
.await?;
let write_response = mcp
.read_stream_until_response_message(RequestId::Integer(write_request_id))
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let stdout_text = read_command_exec_output_until_contains(
mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"hello from stdin",
)
.await?;
assert!(
stdout_text.contains("hello from stdin"),
"unexpected output: {stdout_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
.await?;
let response: CommandExecResponse = to_response(response)?;
assert_eq!(response.exit_code, 0);
assert_eq!(response.stdout, "");
assert_eq!(response.stderr, "");
Ok(())
}
async fn read_initialize_response(
stream: &mut super::connection_handling_websocket::WsClient,
request_id: i64,

View File

@@ -2,7 +2,6 @@ mod account;
mod analytics;
mod app_list;
mod collaboration_mode_list;
#[cfg(unix)]
mod command_exec;
mod compaction;
mod config_rpc;

View File

@@ -40,6 +40,8 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(target_os = "windows")]
use codex_utils_pty::SpawnedProcess;
use dunce::canonicalize;
use macos_permissions::intersect_macos_seatbelt_profile_extensions;
use macos_permissions::merge_macos_seatbelt_profile_extensions;
@@ -747,6 +749,77 @@ pub async fn execute_exec_request_with_after_spawn(
execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await
}
#[cfg(target_os = "windows")]
pub async fn spawn_windows_sandbox_env(
exec_request: ExecRequest,
sandbox_policy_cwd: &Path,
tty: bool,
stdin_open: bool,
) -> crate::error::Result<SpawnedProcess> {
use crate::error::CodexErr;
let ExecRequest {
command,
cwd,
env,
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy,
..
} = exec_request;
if sandbox != SandboxType::WindowsRestrictedToken {
return Err(CodexErr::InvalidRequest(
"spawn_windows_sandbox_env requires a Windows sandbox exec request".to_string(),
));
}
let policy_json = serde_json::to_string(&sandbox_policy).map_err(|err| {
CodexErr::Io(std::io::Error::other(format!(
"failed to serialize Windows sandbox policy: {err}"
)))
})?;
let codex_home = crate::config::find_codex_home().map_err(|err| {
CodexErr::Io(std::io::Error::other(format!(
"windows sandbox: failed to resolve codex_home: {err}"
)))
})?;
match windows_sandbox_level {
WindowsSandboxLevel::Elevated => {
codex_windows_sandbox::spawn_windows_sandbox_session_elevated(
policy_json.as_str(),
sandbox_policy_cwd,
codex_home.as_ref(),
command,
cwd.as_path(),
env,
None,
tty,
stdin_open,
windows_sandbox_private_desktop,
)
.await
.map_err(|err| CodexErr::Io(std::io::Error::other(err.to_string())))
}
_ => codex_windows_sandbox::spawn_windows_sandbox_session_legacy(
policy_json.as_str(),
sandbox_policy_cwd,
codex_home.as_ref(),
command,
cwd.as_path(),
env,
None,
tty,
stdin_open,
windows_sandbox_private_desktop,
)
.await
.map_err(|err| CodexErr::Io(std::io::Error::other(err.to_string()))),
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;

View File

@@ -242,6 +242,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&prepared.exec_request,
req.tty,
prepared.spawn_lifecycle,
None,
None,
)
.await
.map_err(|err| match err {
@@ -275,7 +277,13 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle))
.open_session_with_exec_env(
&exec_env,
req.tty,
Box::new(NoopSpawnLifecycle),
Some(attempt.policy),
Some(attempt.sandbox_cwd),
)
.await
.map_err(|err| match err {
UnifiedExecError::SandboxDenied { output, .. } => {

View File

@@ -367,7 +367,7 @@ fn model_info_from_models_json(slug: &str) -> ModelInfo {
}
#[test]
fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() {
fn unified_exec_is_blocked_for_windows_sandboxed_policies() {
assert!(!unified_exec_allowed_in_environment(
true,
&SandboxPolicy::new_read_only_policy(),
@@ -391,7 +391,7 @@ fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() {
}
#[test]
fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
fn model_provided_unified_exec_falls_back_for_windows_sandboxed_policies() {
let mut model_info = model_info_from_models_json("gpt-5-codex");
model_info.shell_type = ConfigShellToolType::UnifiedExec;
let features = Features::with_defaults();
@@ -411,6 +411,7 @@ fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
} else {
ConfigShellToolType::UnifiedExec
};
assert_eq!(config.shell_type, expected_shell_type);
}

View File

@@ -50,6 +50,7 @@ use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use codex_protocol::protocol::SandboxPolicy;
const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [
("NO_COLOR", "1"),
@@ -542,6 +543,8 @@ impl UnifiedExecProcessManager {
env: &ExecRequest,
tty: bool,
mut spawn_lifecycle: SpawnLifecycleHandle,
policy: Option<&SandboxPolicy>,
sandbox_policy_cwd: Option<&std::path::Path>,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = env
.command
@@ -549,6 +552,69 @@ impl UnifiedExecProcessManager {
.ok_or(UnifiedExecError::MissingCommandLine)?;
let inherited_fds = spawn_lifecycle.inherited_fds();
#[cfg(target_os = "windows")]
if env.sandbox == crate::exec::SandboxType::WindowsRestrictedToken {
let policy = policy.ok_or_else(|| {
UnifiedExecError::create_process(
"missing Windows sandbox policy for unified exec".to_string(),
)
})?;
let sandbox_policy_cwd = sandbox_policy_cwd.ok_or_else(|| {
UnifiedExecError::create_process(
"missing Windows sandbox cwd for unified exec".to_string(),
)
})?;
let policy_json = serde_json::to_string(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 env.windows_sandbox_level {
codex_protocol::config_types::WindowsSandboxLevel::Elevated => {
codex_windows_sandbox::spawn_windows_sandbox_session_elevated(
policy_json.as_str(),
sandbox_policy_cwd,
codex_home.as_ref(),
env.command.clone(),
env.cwd.as_path(),
env.env.clone(),
None,
tty,
tty,
env.windows_sandbox_private_desktop,
)
.await
}
_ => {
codex_windows_sandbox::spawn_windows_sandbox_session_legacy(
policy_json.as_str(),
sandbox_policy_cwd,
codex_home.as_ref(),
env.command.clone(),
env.cwd.as_path(),
env.env.clone(),
None,
tty,
tty,
env.windows_sandbox_private_desktop,
)
.await
}
};
spawn_lifecycle.after_spawn();
return UnifiedExecProcess::from_spawned(
spawned.map_err(|err| UnifiedExecError::create_process(err.to_string()))?,
env.sandbox,
spawn_lifecycle,
)
.await;
}
let spawn_result = if tty {
codex_utils_pty::pty::spawn_process_with_inherited_fds(
program,

View File

@@ -15,6 +15,10 @@ pub use pipe::spawn_process as spawn_pipe_process;
pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin;
/// Combine stdout/stderr receivers into a single broadcast receiver.
pub use process::combine_output_receivers;
/// Build a spawned process from externally managed stdin/output/exit channels.
pub use process::spawn_from_driver;
/// Driver-backed process wiring for externally managed backends.
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.

View File

@@ -239,6 +239,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,8 @@ impl fmt::Debug for PtyHandles {
}
}
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 +85,7 @@ pub struct ProcessHandle {
// PtyHandles must be preserved because the process will receive Control+C if the
// slave is closed
_pty_handles: StdMutex<Option<PtyHandles>>,
resizer: StdMutex<Option<ResizeFn>>,
}
impl fmt::Debug for ProcessHandle {
@@ -102,6 +106,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 +118,7 @@ impl ProcessHandle {
exit_status,
exit_code,
_pty_handles: StdMutex::new(pty_handles),
resizer: StdMutex::new(resizer),
}
}
@@ -145,13 +151,23 @@ impl ProcessHandle {
._pty_handles
.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),
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),
};
}
drop(handles);
let mut resizer = self
.resizer
.lock()
.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 +221,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 +293,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,
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),
None,
);
Ok(SpawnedProcess {
@@ -395,6 +396,7 @@ async fn spawn_process_preserving_fds(
exit_status,
exit_code,
Some(handles),
None,
);
Ok(SpawnedProcess {

View File

@@ -8,9 +8,11 @@ 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;
use crate::ProcessDriver;
use crate::SpawnedProcess;
use crate::TerminalSize;
@@ -590,6 +592,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() {
if 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,8 @@ fn windows_build_number() -> Option<u32> {
pub struct PsuedoCon {
con: HPCON,
_input: FileDescriptor,
_output: FileDescriptor,
}
unsafe impl Send for PsuedoCon {}
@@ -149,7 +151,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,12 +6,10 @@
//! `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::argv_to_command_line;
use crate::winutil::format_last_error;
use crate::winutil::quote_windows_arg;
use crate::winutil::to_wide;
use anyhow::Result;
use codex_utils_pty::RawConPty;
@@ -37,7 +35,7 @@ pub struct ConptyInstance {
pub hpc: HANDLE,
pub input_write: HANDLE,
pub output_read: HANDLE,
_desktop: LaunchDesktop,
desktop: LaunchDesktop,
}
impl Drop for ConptyInstance {
@@ -58,28 +56,13 @@ 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, 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)
}
}
/// Create a ConPTY with backing pipes.
///
/// 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`.
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();
Ok(ConptyInstance {
hpc: hpc as HANDLE,
input_write: input_write as HANDLE,
output_read: output_read as HANDLE,
_desktop: LaunchDesktop::prepare(false, None)?,
})
}
/// Spawn a process under `h_token` with ConPTY attached.
///
/// This is the main shared ConPTY entry point and is used by both the legacy/direct path
@@ -92,12 +75,13 @@ pub fn spawn_conpty_process_as_user(
use_private_desktop: bool,
logs_base_dir: Option<&Path>,
) -> Result<(PROCESS_INFORMATION, ConptyInstance)> {
let cmdline_str = argv
.iter()
.map(|arg| quote_windows_arg(arg))
.collect::<Vec<_>>()
.join(" ");
let cmdline_str = argv_to_command_line(argv);
let mut cmdline: Vec<u16> = to_wide(&cmdline_str);
let application_name = argv
.first()
.map(Path::new)
.filter(|path| path.is_absolute())
.map(to_wide);
let env_block = make_env_block(env_map);
let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
@@ -108,7 +92,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(80, 24)?;
let raw = RawConPty::new(80, 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,
};
let mut attrs = ProcThreadAttributeList::new(1)?;
attrs.set_pseudoconsole(conpty.hpc)?;
si.lpAttributeList = attrs.as_mut_ptr();
@@ -117,7 +108,9 @@ pub fn spawn_conpty_process_as_user(
let ok = unsafe {
CreateProcessAsUserW(
h_token,
std::ptr::null(),
application_name
.as_ref()
.map_or(std::ptr::null(), std::vec::Vec::as_ptr),
cmdline.as_mut_ptr(),
std::ptr::null_mut(),
std::ptr::null_mut(),
@@ -140,7 +133,5 @@ pub fn spawn_conpty_process_as_user(
env_block.len()
));
}
let mut conpty = conpty;
conpty._desktop = desktop;
Ok((pi, conpty))
}

View File

@@ -27,11 +27,12 @@ use codex_windows_sandbox::ipc_framed::FramedMessage;
use codex_windows_sandbox::ipc_framed::Message;
use codex_windows_sandbox::ipc_framed::OutputPayload;
use codex_windows_sandbox::ipc_framed::OutputStream;
use codex_windows_sandbox::log_note;
use codex_windows_sandbox::ipc_framed::ResizePayload;
use codex_windows_sandbox::parse_policy;
use codex_windows_sandbox::read_handle_loop;
use codex_windows_sandbox::spawn_process_with_pipes;
use codex_windows_sandbox::to_wide;
use codex_windows_sandbox::LaunchDesktop;
use codex_windows_sandbox::PipeSpawnHandles;
use codex_windows_sandbox::SandboxPolicy;
use codex_windows_sandbox::StderrMode;
@@ -54,6 +55,8 @@ 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::ClosePseudoConsole;
use windows_sys::Win32::System::Console::ResizePseudoConsole;
use windows_sys::Win32::System::Console::COORD;
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
use windows_sys::Win32::System::JobObjects::CreateJobObjectW;
use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation;
@@ -83,6 +86,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> {
@@ -162,21 +167,9 @@ fn read_spawn_request(
fn effective_cwd(req_cwd: &Path, log_dir: Option<&Path>) -> PathBuf {
let use_junction = match read_acl_mutex::read_acl_mutex_exists() {
Ok(exists) => exists,
Err(err) => {
log_note(
&format!(
"junction: read_acl_mutex_exists failed: {err}; assuming read ACL helper is running"
),
log_dir,
);
true
}
Err(_) => true,
};
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()
@@ -188,16 +181,6 @@ fn spawn_ipc_process(
) -> 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 {
@@ -241,16 +224,10 @@ fn spawn_ipc_process(
}
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,
@@ -260,8 +237,9 @@ fn spawn_ipc_process(
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 = Some(desktop);
let stdin_handle = if req.stdin_open {
Some(input_write)
} else {
@@ -282,29 +260,29 @@ fn spawn_ipc_process(
} 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,
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,
@@ -312,6 +290,8 @@ fn spawn_ipc_process(
stderr_handle,
stdin_handle,
hpc_handle,
_desktop_owner: desktop_owner,
_pipe_handles: pipe_handles,
})
}
@@ -320,7 +300,7 @@ fn spawn_output_reader(
writer: Arc<StdMutex<File>>,
handle: HANDLE,
stream: OutputStream,
log_dir: Option<PathBuf>,
_log_dir: Option<PathBuf>,
) -> std::thread::JoinHandle<()> {
read_handle_loop(handle, move |chunk| {
let msg = FramedMessage {
@@ -333,12 +313,7 @@ fn spawn_output_reader(
},
};
if let Ok(mut guard) = writer.lock() {
if let Err(err) = write_frame(&mut *guard, &msg) {
log_note(
&format!("runner output write failed: {err}"),
log_dir.as_deref(),
);
}
let _ = write_frame(&mut *guard, &msg);
}
})
}
@@ -347,21 +322,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 } => {
@@ -381,6 +352,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() {
if 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() {
if let Some(handle) = guard.as_ref() {
@@ -451,7 +446,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 {
@@ -475,7 +470,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);
}
@@ -500,6 +494,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,
);
@@ -518,9 +513,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);
}
@@ -531,10 +523,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() {
if 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 {
@@ -545,9 +547,7 @@ pub fn main() -> Result<()> {
},
};
if let Ok(mut guard) = pipe_write.lock() {
if let Err(err) = write_frame(&mut *guard, &exit_msg) {
log_note(&format!("runner exit write failed: {err}"), log_dir);
}
let _ = write_frame(&mut *guard, &exit_msg);
}
std::process::exit(exit_code);

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

@@ -6,14 +6,14 @@
//! and elevated capture. The legacy restrictedtoken path spawns the child directly
//! and does not use these helpers.
use crate::helper_materialization::HelperExecutable;
use crate::helper_materialization::resolve_helper_for_launch;
use crate::helper_materialization::HelperExecutable;
use crate::winutil::resolve_sid;
use crate::winutil::string_from_sid_bytes;
use crate::winutil::to_wide;
use rand::rngs::SmallRng;
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::SmallRng;
use std::io;
use std::path::Path;
use std::path::PathBuf;

View File

@@ -1,14 +1,6 @@
mod windows_impl {
use crate::acl::allow_null_device;
use crate::allow::compute_allow_paths;
use crate::allow::AllowDenyPaths;
use crate::cap::load_or_create_cap_sids;
use crate::env::ensure_non_interactive_pager;
use crate::env::inherit_path_env;
use crate::env::normalize_null_device_env;
use crate::helper_materialization::resolve_helper_for_launch;
use crate::helper_materialization::HelperExecutable;
use crate::identity::require_logon_sandbox_creds;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::read_frame;
use crate::ipc_framed::write_frame;
@@ -17,12 +9,8 @@ mod windows_impl {
use crate::ipc_framed::OutputStream;
use crate::ipc_framed::SpawnRequest;
use crate::logging::log_failure;
use crate::logging::log_note;
use crate::logging::log_start;
use crate::logging::log_success;
use crate::policy::parse_policy;
use crate::policy::SandboxPolicy;
use crate::token::convert_string_sid_to_sid;
use crate::spawn_prep::prepare_elevated_spawn_context;
use crate::winutil::quote_windows_arg;
use crate::winutil::resolve_sid;
use crate::winutil::string_from_sid_bytes;
@@ -58,67 +46,6 @@ mod windows_impl {
use windows_sys::Win32::System::Threading::PROCESS_INFORMATION;
use windows_sys::Win32::System::Threading::STARTUPINFOW;
/// Ensures the parent directory of a path exists before writing to it.
/// Walks upward from `start` to locate the git worktree root, following gitfile redirects.
fn find_git_root(start: &Path) -> Option<PathBuf> {
let mut cur = dunce::canonicalize(start).ok()?;
loop {
let marker = cur.join(".git");
if marker.is_dir() {
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));
}
}
return Some(cur);
}
let parent = cur.parent()?;
if parent == cur {
return None;
}
cur = parent.to_path_buf();
}
}
/// Creates the sandbox user's Codex home directory if it does not already exist.
fn ensure_codex_home_exists(p: &Path) -> Result<()> {
std::fs::create_dir_all(p)?;
Ok(())
}
/// Adds a git safe.directory entry to the environment when running inside a repository.
/// git will not otherwise allow the Sandbox user to run git commands on the repo directory
/// which is owned by the primary user.
fn inject_git_safe_directory(
env_map: &mut HashMap<String, String>,
cwd: &Path,
_logs_base_dir: Option<&Path>,
) {
if let Some(git_root) = find_git_root(cwd) {
let mut cfg_count: usize = env_map
.get("GIT_CONFIG_COUNT")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0);
let git_path = git_root.to_string_lossy().replace("\\\\", "/");
env_map.insert(
format!("GIT_CONFIG_KEY_{cfg_count}"),
"safe.directory".to_string(),
);
env_map.insert(format!("GIT_CONFIG_VALUE_{cfg_count}"), git_path);
cfg_count += 1;
env_map.insert("GIT_CONFIG_COUNT".to_string(), cfg_count.to_string());
}
}
/// Resolves the command runner path, preferring CODEX_HOME/.sandbox/bin.
fn find_runner_exe(codex_home: &Path, log_dir: Option<&Path>) -> PathBuf {
resolve_helper_for_launch(HelperExecutable::CommandRunner, codex_home, log_dir)
@@ -212,56 +139,21 @@ mod windows_impl {
timeout_ms: Option<u64>,
use_private_desktop: bool,
) -> Result<CaptureResult> {
let policy = parse_policy(policy_json_or_preset)?;
normalize_null_device_env(&mut env_map);
ensure_non_interactive_pager(&mut env_map);
inherit_path_env(&mut env_map);
inject_git_safe_directory(&mut env_map, cwd, None);
let current_dir = cwd.to_path_buf();
// Use a temp-based log dir that the sandbox user can write.
let sandbox_base = codex_home.join(".sandbox");
ensure_codex_home_exists(&sandbox_base)?;
let logs_base_dir: Option<&Path> = Some(sandbox_base.as_path());
log_start(&command, logs_base_dir);
let sandbox_creds =
require_logon_sandbox_creds(&policy, sandbox_policy_cwd, cwd, &env_map, codex_home)?;
let sandbox_sid = resolve_sid(&sandbox_creds.username).map_err(|err: anyhow::Error| {
io::Error::new(io::ErrorKind::PermissionDenied, err.to_string())
})?;
let elevated = prepare_elevated_spawn_context(
policy_json_or_preset,
sandbox_policy_cwd,
codex_home,
cwd,
&mut env_map,
&command,
)?;
let logs_base_dir: Option<&Path> = Some(elevated.common.sandbox_base.as_path());
let sandbox_sid =
resolve_sid(&elevated.sandbox_creds.username).map_err(|err: anyhow::Error| {
io::Error::new(io::ErrorKind::PermissionDenied, err.to_string())
})?;
let sandbox_sid = string_from_sid_bytes(&sandbox_sid)
.map_err(|err| io::Error::new(io::ErrorKind::PermissionDenied, err))?;
// Build capability SID for ACL grants.
if matches!(
&policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
) {
anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing")
}
let caps = load_or_create_cap_sids(codex_home)?;
let (psid_to_use, cap_sids) = match &policy {
SandboxPolicy::ReadOnly { .. } => (
unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() },
vec![caps.readonly.clone()],
),
SandboxPolicy::WorkspaceWrite { .. } => (
unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() },
vec![
caps.workspace.clone(),
crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?,
],
),
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!("DangerFullAccess handled above")
}
};
let AllowDenyPaths { allow: _, deny: _ } =
compute_allow_paths(&policy, sandbox_policy_cwd, &current_dir, &env_map);
// Deny/allow ACEs are now applied during setup; avoid per-command churn.
unsafe {
allow_null_device(psid_to_use);
}
let pipe_in_name = pipe_name("in");
let pipe_out_name = pipe_name("out");
@@ -272,7 +164,7 @@ mod windows_impl {
let runner_exe = find_runner_exe(codex_home, logs_base_dir);
let runner_cmdline = runner_exe
.to_str()
.map(|s| s.to_string())
.map(|s: &str| s.to_string())
.unwrap_or_else(|| "codex-command-runner.exe".to_string());
let runner_full_cmd = format!(
"{} {} {}",
@@ -289,22 +181,12 @@ mod windows_impl {
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 user_w = to_wide(&sandbox_creds.username);
let user_w = to_wide(&elevated.sandbox_creds.username);
let domain_w = to_wide(".");
let password_w = to_wide(&sandbox_creds.password);
let password_w = to_wide(&elevated.sandbox_creds.password);
// Suppress WER/UI popups from the runner process so we can collect exit codes.
let _ = unsafe { SetErrorMode(0x0001 | 0x0002) }; // SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX
log_note(
&format!(
"runner launch: exe={} cmdline={} cwd={}",
runner_exe.display(),
runner_full_cmd,
cwd.display()
),
logs_base_dir,
);
// Ensure command line buffer is mutable and includes the exe as argv[0].
let spawn_res = unsafe {
CreateProcessWithLogonW(
@@ -327,14 +209,6 @@ mod windows_impl {
};
if spawn_res == 0 {
let err = unsafe { GetLastError() } as i32;
log_note(
&format!(
"runner launch failed before process start: exe={} cmdline={} error={err}",
runner_exe.display(),
runner_full_cmd
),
logs_base_dir,
);
return Err(anyhow::anyhow!("CreateProcessWithLogonW failed: {}", err));
}
@@ -378,9 +252,9 @@ mod windows_impl {
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: sandbox_base.clone(),
codex_home: elevated.common.sandbox_base.clone(),
real_codex_home: codex_home.to_path_buf(),
cap_sids,
cap_sids: elevated.cap_sids.clone(),
timeout_ms,
tty: false,
stdin_open: false,
@@ -406,6 +280,9 @@ mod windows_impl {
OutputStream::Stderr => stderr.extend_from_slice(&bytes),
}
}
Message::Stdin { .. } => {}
Message::CloseStdin { .. } => {}
Message::Resize { .. } => {}
Message::Exit { payload } => break (payload.exit_code, payload.timed_out),
Message::Error { payload } => {
return Err(anyhow::anyhow!("runner error: {}", payload.message));

View File

@@ -28,6 +28,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 mod ipc_framed;
@@ -39,9 +43,25 @@ 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")]
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;
@@ -66,6 +86,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;
@@ -104,6 +126,10 @@ pub use process::StderrMode;
#[cfg(target_os = "windows")]
pub use process::StdinMode;
#[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::run_elevated_setup;
#[cfg(target_os = "windows")]
pub use setup::run_setup_refresh;
@@ -179,16 +205,13 @@ mod windows_impl {
use super::allow::AllowDenyPaths;
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::parse_policy;
use super::policy::SandboxPolicy;
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;
@@ -213,15 +236,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;
@@ -268,27 +282,19 @@ mod windows_impl {
timeout_ms: Option<u64>,
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,
false,
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"
@@ -372,7 +378,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 {
@@ -503,7 +508,6 @@ mod windows_impl {
}
}
}
Ok(CaptureResult {
exit_code,
stdout,
@@ -535,7 +539,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) {
@@ -559,8 +562,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::UpdateProcThreadAttribute;
use windows_sys::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST;
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,29 @@ 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 handle_list = self.handle_list.as_mut().expect("handle list just set");
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(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,6 +219,7 @@ 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.
@@ -190,6 +231,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 +271,7 @@ pub fn spawn_process_with_pipes(
argv,
cwd,
env_map,
None,
logs_base_dir,
stdio,
use_private_desktop,
)
@@ -250,7 +292,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 +320,7 @@ pub fn spawn_process_with_pipes(
StderrMode::Separate => Some(err_r),
StderrMode::MergeStdout => None,
},
desktop,
})
}

View File

@@ -0,0 +1,270 @@
use crate::acl::add_allow_ace;
use crate::acl::add_deny_write_ace;
use crate::acl::allow_null_device;
use crate::allow::compute_allow_paths;
use crate::allow::AllowDenyPaths;
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::require_logon_sandbox_creds;
use crate::identity::SandboxCreds;
use crate::logging::log_start;
use crate::path_normalization::canonicalize_path;
use crate::policy::parse_policy;
use crate::policy::SandboxPolicy;
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;
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: *mut c_void,
pub(crate) psid_workspace: Option<*mut c_void>,
pub(crate) cap_sid_str: String,
}
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): (
HANDLE,
*mut c_void,
Option<*mut c_void>,
String,
) = unsafe {
match policy {
SandboxPolicy::ReadOnly { .. } => {
let psid = convert_string_sid_to_sid(&caps.readonly).unwrap();
let (h_token, psid) = create_readonly_token_with_cap(psid)?;
(h_token, psid, None, caps.readonly.clone())
}
SandboxPolicy::WorkspaceWrite { .. } => {
let psid_generic = convert_string_sid_to_sid(&caps.workspace).unwrap();
let workspace_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?;
let psid_workspace = convert_string_sid_to_sid(&workspace_sid).unwrap();
let base = get_current_token_for_restriction()?;
let h_token = create_workspace_write_token_with_caps_from(
base,
&[psid_generic, psid_workspace],
)?;
CloseHandle(base);
(
h_token,
psid_generic,
Some(psid_workspace),
caps.workspace.clone(),
)
}
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.clone();
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: *mut c_void,
psid_workspace: Option<*mut c_void>,
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)
} else {
psid_generic
};
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) {
if added && !persist_aces {
guards.push(p.clone());
}
}
}
allow_null_device(psid_generic);
if let Some(psid_workspace) = psid_workspace {
allow_null_device(psid_workspace);
if persist_aces && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) {
let _ = protect_workspace_codex_dir(current_dir, psid_workspace);
let _ = protect_workspace_agents_dir(current_dir, psid_workspace);
}
}
}
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,
true,
true,
)?;
let sandbox_creds =
require_logon_sandbox_creds(&common.policy, sandbox_policy_cwd, cwd, env_map, codex_home)?;
let caps = load_or_create_cap_sids(codex_home)?;
let (psid_to_use, cap_sids) = match &common.policy {
SandboxPolicy::ReadOnly { .. } => (
unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() },
vec![caps.readonly.clone()],
),
SandboxPolicy::WorkspaceWrite { .. } => {
let cap_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?;
(
unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() },
vec![caps.workspace.clone(), cap_sid],
)
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!("dangerous policies rejected before elevated session prep")
}
};
let AllowDenyPaths { allow: _, deny: _ } = compute_allow_paths(
&common.policy,
sandbox_policy_cwd,
&common.current_dir,
env_map,
);
unsafe {
allow_null_device(psid_to_use);
}
Ok(ElevatedSpawnContext {
common,
sandbox_creds,
cap_sids,
})
}

View File

@@ -0,0 +1,820 @@
//! Unified exec session spawner for Windows sandboxing.
//!
//! This module implements the **unified_exec session** paths for Windows by returning a
//! longlived `SpawnedProcess` wired for stdin/out/exit. It covers both the legacy
//! restrictedtoken path (direct spawn under a restricted token) and the elevated path
//! (spawn via the command runner IPC). It is not used for nonunified exec capture flows,
//! which continue to use the oneshot capture APIs.
use crate::acl::revoke_ace;
use crate::conpty::spawn_conpty_process_as_user;
use crate::desktop::LaunchDesktop;
use crate::identity::SandboxCreds;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::encode_bytes;
use crate::ipc_framed::read_frame;
use crate::ipc_framed::write_frame;
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::SpawnRequest;
use crate::ipc_framed::StdinPayload;
use crate::logging::log_failure;
use crate::logging::log_success;
use crate::process::read_handle_loop;
use crate::process::spawn_process_with_pipes;
use crate::process::StderrMode;
use crate::process::StdinMode;
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::runner_pipe::PIPE_ACCESS_INBOUND;
use crate::runner_pipe::PIPE_ACCESS_OUTBOUND;
use crate::spawn_prep::allow_null_device_for_workspace_write;
use crate::spawn_prep::apply_legacy_session_acl_rules;
use crate::spawn_prep::prepare_elevated_spawn_context;
use crate::spawn_prep::prepare_legacy_session_security;
use crate::spawn_prep::prepare_legacy_spawn_context;
use crate::token::convert_string_sid_to_sid;
use crate::winutil::quote_windows_arg;
use crate::winutil::to_wide;
use anyhow::Result;
use codex_utils_pty::spawn_from_driver;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::SpawnedProcess;
use codex_utils_pty::TerminalSize;
use std::collections::HashMap;
use std::ffi::c_void;
use std::fs::File;
use std::os::windows::io::FromRawHandle;
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::ClosePseudoConsole;
use windows_sys::Win32::System::Console::ResizePseudoConsole;
use windows_sys::Win32::System::Console::COORD;
use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode;
use windows_sys::Win32::System::Threading::CreateProcessWithLogonW;
use windows_sys::Win32::System::Threading::GetExitCodeProcess;
use windows_sys::Win32::System::Threading::TerminateProcess;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
use windows_sys::Win32::System::Threading::INFINITE;
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 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>,
}
/// Spawn the restricted-token child directly and attach either pipe or ConPTY I/O.
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), Some(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 stderr_join = spawn_output_reader(
pipe_handles
.stderr_read
.expect("separate stderr handle should be present"),
stderr_tx.expect("separate stderr channel should be present"),
);
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,
})
}
/// Read process output and forward chunks into a broadcast channel.
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 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
}
/// Write stdin chunks from a channel into the child process input handle.
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
};
let mut written: u32 = 0;
unsafe {
let _ = WriteFile(
handle,
bytes.as_ptr(),
bytes.len() as u32,
&mut written,
ptr::null_mut(),
);
}
}
if let Some(handle) = input_write {
unsafe {
CloseHandle(handle);
}
}
})
}
/// Start the elevated runner under the sandbox user and connect the parent-side IPC pipes.
fn launch_runner_pipes(
codex_home: &Path,
cwd: &Path,
sandbox_creds: &SandboxCreds,
pipe_in: String,
pipe_out: String,
) -> Result<(File, File)> {
let h_pipe_in = create_named_pipe(&pipe_in, PIPE_ACCESS_OUTBOUND, &sandbox_creds.username)?;
let h_pipe_out = create_named_pipe(&pipe_out, PIPE_ACCESS_INBOUND, &sandbox_creds.username)?;
let runner_exe = find_runner_exe(codex_home, None);
let runner_cmdline = runner_exe
.to_str()
.map(|s| s.to_string())
.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}")),
quote_windows_arg(&format!("--pipe-out={pipe_out}"))
);
let mut cmdline_vec: Vec<u16> = to_wide(&runner_full_cmd);
let exe_w: Vec<u16> = to_wide(&runner_cmdline);
let cwd_w: Vec<u16> = to_wide(cwd);
let env_block: Option<Vec<u16>> = None;
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 user_w = to_wide(&sandbox_creds.username);
let domain_w = to_wide(".");
let password_w = to_wide(&sandbox_creds.password);
let _ = unsafe { SetErrorMode(0x0001 | 0x0002) };
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(|b| b.as_ptr() as *const c_void)
.unwrap_or(ptr::null()),
cwd_w.as_ptr(),
&si,
&mut pi,
)
};
if spawn_res == 0 {
let err = unsafe { GetLastError() } as i32;
return Err(anyhow::anyhow!("CreateProcessWithLogonW failed: {}", err));
}
if let Err(err) = connect_pipe(h_pipe_in) {
unsafe {
CloseHandle(h_pipe_in);
CloseHandle(h_pipe_out);
if pi.hThread != 0 {
CloseHandle(pi.hThread);
}
if pi.hProcess != 0 {
CloseHandle(pi.hProcess);
}
}
return Err(err.into());
}
if let Err(err) = connect_pipe(h_pipe_out) {
unsafe {
CloseHandle(h_pipe_in);
CloseHandle(h_pipe_out);
if pi.hThread != 0 {
CloseHandle(pi.hThread);
}
if pi.hProcess != 0 {
CloseHandle(pi.hProcess);
}
}
return Err(err.into());
}
unsafe {
if pi.hThread != 0 {
CloseHandle(pi.hThread);
}
if pi.hProcess != 0 {
CloseHandle(pi.hProcess);
}
}
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((pipe_write, pipe_read))
}
/// Send the initial spawn request that tells the elevated runner what child to create.
fn send_spawn_request(pipe_write: &mut File, request: SpawnRequest) -> Result<()> {
let spawn_request = FramedMessage {
version: 1,
message: Message::SpawnRequest {
payload: Box::new(request),
},
};
write_frame(pipe_write, &spawn_request)?;
Ok(())
}
/// Wait for the runner to acknowledge spawn success or return a structured startup error.
fn read_spawn_ready(pipe_read: &mut File) -> Result<()> {
let first = read_frame(pipe_read)?
.ok_or_else(|| anyhow::anyhow!("runner pipe closed before spawn_ready"))?;
match first.message {
Message::SpawnReady { .. } => Ok(()),
Message::Error { payload } => Err(anyhow::anyhow!("runner error: {}", payload.message)),
other => Err(anyhow::anyhow!("unexpected runner message: {other:?}")),
}
}
/// Forward stdin chunks from the process driver into framed IPC messages for the runner.
fn start_runner_stdin_writer(
mut writer_rx: mpsc::Receiver<Vec<u8>>,
pipe_write: Arc<StdMutex<File>>,
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 let Ok(mut guard) = pipe_write.lock() {
let _ = write_frame(&mut *guard, &msg);
} else {
break;
}
}
if stdin_open {
let msg = FramedMessage {
version: 1,
message: Message::CloseStdin {
payload: EmptyPayload::default(),
},
};
if let Ok(mut guard) = pipe_write.lock() {
let _ = write_frame(&mut *guard, &msg);
}
}
})
}
/// Translate framed runner output and exit messages back into the process driver channels.
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 read_frame(&mut pipe_read) {
Ok(Some(v)) => v,
Ok(None) => {
let _ = exit_tx.send(-1);
break;
}
Err(_err) => {
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: _ } => {
let _ = exit_tx.send(-1);
break;
}
Message::SpawnReady { .. } => {}
Message::Stdin { .. } => {}
Message::CloseStdin { .. } => {}
Message::Resize { .. } => {}
Message::SpawnRequest { .. } => {}
Message::Terminate { .. } => {}
}
});
}
/// Finalize process exit, emit exit code, and cleanup handles/ACLs.
#[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: u32 = 1;
if let Ok(guard) = process_handle.lock() {
if let Some(handle) = guard.as_ref() {
unsafe {
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() {
if 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 !guards.is_empty() {
if let Some(cap_sid) = cap_sid {
if let Some(sid) = unsafe { convert_string_sid_to_sid(&cap_sid) } {
unsafe {
for p in guards {
revoke_ace(&p, sid);
}
}
}
}
}
// exit_tx already sent above.
}
fn finish_driver_spawn(driver: ProcessDriver, stdin_open: bool) -> SpawnedProcess {
let spawned = spawn_from_driver(driver);
if !stdin_open {
spawned.session.close_stdin();
}
spawned
}
fn resize_conpty_handle(
hpc: &Arc<StdMutex<Option<HANDLE>>>,
size: TerminalSize,
) -> anyhow::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}"
))
}
}
fn make_runner_resizer(
pipe_write: Arc<StdMutex<File>>,
) -> Box<dyn FnMut(TerminalSize) -> anyhow::Result<()> + Send> {
Box::new(move |size: TerminalSize| {
let msg = FramedMessage {
version: 1,
message: Message::Resize {
payload: ResizePayload {
rows: size.rows,
cols: size.cols,
},
},
};
let mut guard = pipe_write
.lock()
.map_err(|_| anyhow::anyhow!("runner resize pipe lock poisoned"))?;
write_frame(&mut *guard, &msg)
})
}
#[allow(clippy::too_many_arguments)]
/// Spawn a sandboxed process under a restricted token and return a live session.
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,
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,
false,
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,
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.clone(),
stderr_rx.as_ref().map(|(tx, _rx)| tx.clone()),
writer_rx,
common.logs_base_dir.as_deref(),
) {
Ok(handles) => handles,
Err(err) => {
unsafe {
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.clone())
};
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() {
if let Some(handle) = guard.as_ref() {
let _ = TerminateProcess(*handle, 1);
}
}
}
}
if let Some(hpc) = hpc_for_wait {
if let Ok(mut guard) = hpc.lock() {
if 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() {
if 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) -> anyhow::Result<()> + Send>
}),
};
Ok(finish_driver_spawn(driver, stdin_open))
}
#[allow(clippy::too_many_arguments)]
/// Spawn a sandboxed process via the elevated runner IPC path and return a live session.
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,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
tty: bool,
stdin_open: bool,
use_private_desktop: bool,
) -> Result<SpawnedProcess> {
let _ = timeout_ms;
let elevated = prepare_elevated_spawn_context(
policy_json_or_preset,
sandbox_policy_cwd,
codex_home,
cwd,
&mut env_map,
&command,
)?;
let (pipe_in, pipe_out) = pipe_pair();
let (mut pipe_write, mut pipe_read) =
launch_runner_pipes(codex_home, cwd, &elevated.sandbox_creds, pipe_in, pipe_out)?;
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,
};
send_spawn_request(&mut pipe_write, spawn_request)?;
read_spawn_ready(&mut pipe_read)?;
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 pipe_write = Arc::new(StdMutex::new(pipe_write));
let writer_handle =
start_runner_stdin_writer(writer_rx, Arc::clone(&pipe_write), tty, stdin_open);
let terminator = {
let pipe_write = Arc::clone(&pipe_write);
Some(Box::new(move || {
if let Ok(mut guard) = pipe_write.lock() {
let msg = FramedMessage {
version: 1,
message: Message::Terminate {
payload: EmptyPayload::default(),
},
};
let _ = write_frame(&mut *guard, &msg);
}
}) 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(Arc::clone(&pipe_write)))
} else {
None
},
},
stdin_open,
))
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,519 @@
#![cfg(target_os = "windows")]
use super::spawn_windows_sandbox_session_legacy;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::read_frame;
use crate::ipc_framed::Message;
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 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()))
}
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 pipe_write = std::sync::Arc::new(std::sync::Mutex::new(file));
let (writer_tx, writer_rx) = mpsc::channel::<Vec<u8>>(1);
let writer_handle = super::start_runner_stdin_writer(
writer_rx, pipe_write, /*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 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 stdin_frame = read_frame(&mut reader)
.expect("read stdin frame")
.expect("stdin frame should be present");
match stdin_frame.message {
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:?}"),
}
let close_frame = read_frame(&mut reader)
.expect("read close-stdin frame")
.expect("close-stdin frame should be present");
match close_frame.message {
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 pipe_write = std::sync::Arc::new(std::sync::Mutex::new(file));
let mut resizer = super::make_runner_resizer(pipe_write);
resizer(codex_utils_pty::TerminalSize {
rows: 45,
cols: 132,
})
.expect("send resize frame");
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 resize_frame = read_frame(&mut reader)
.expect("read resize frame")
.expect("resize frame should be present");
match resize_frame.message {
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,61 @@ pub fn quote_windows_arg(arg: &str) -> String {
quoted
}
fn is_cmd_executable(program: &str) -> bool {
let lower = std::path::Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(program)
.to_ascii_lowercase();
matches!(lower.as_str(), "cmd" | "cmd.exe")
}
/// Build a Windows command line for CreateProcess-style APIs.
///
/// Most programs should receive CRT-style quoted arguments. `cmd.exe` is the exception:
/// the tokens after the executable are part of cmd's own command string, so escaping inner
/// quotes with CRT rules breaks `cmd /c "<program with spaces>" ...`.
#[cfg(target_os = "windows")]
pub fn argv_to_command_line(argv: &[String]) -> String {
let Some((program, args)) = argv.split_first() else {
return String::new();
};
if is_cmd_executable(program) {
if args.is_empty() {
return quote_windows_arg(program);
}
let cmd_switch_index = args
.iter()
.position(|arg| arg.eq_ignore_ascii_case("/c") || arg.eq_ignore_ascii_case("/k"));
let rendered_args = if let Some(index) = cmd_switch_index {
let mut rendered = args[..=index]
.iter()
.map(|arg| quote_windows_arg(arg))
.collect::<Vec<_>>();
let suffix = &args[index + 1..];
if suffix.len() == 1 {
rendered.push(suffix[0].clone());
} else {
rendered.extend(suffix.iter().map(|arg| quote_windows_arg(arg)));
}
rendered.join(" ")
} else {
args.iter()
.map(|arg| quote_windows_arg(arg))
.collect::<Vec<_>>()
.join(" ")
};
return format!("{} {rendered_args}", quote_windows_arg(program));
}
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 {
@@ -111,6 +166,58 @@ pub fn string_from_sid_bytes(sid: &[u8]) -> Result<String, String> {
}
}
#[cfg(test)]
mod tests {
use super::argv_to_command_line;
use pretty_assertions::assert_eq;
#[test]
fn argv_to_command_line_preserves_raw_cmd_suffix() {
let argv = vec![
"cmd.exe".to_string(),
"/d".to_string(),
"/s".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 /d /s /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\\\"\""
);
}
#[test]
fn argv_to_command_line_quotes_cmd_suffix_tokens_with_spaces() {
let argv = vec![
"cmd.exe".to_string(),
"/c".to_string(),
"type".to_string(),
"C:\\Program Files\\a.txt".to_string(),
];
assert_eq!(
argv_to_command_line(&argv),
"cmd.exe /c type \"C:\\Program Files\\a.txt\""
);
}
}
const SID_ADMINISTRATORS: &str = "S-1-5-32-544";
const SID_USERS: &str = "S-1-5-32-545";
const SID_AUTHENTICATED_USERS: &str = "S-1-5-11";