mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
10 Commits
automation
...
windows-sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac69fd5ba | ||
|
|
969be7c0ad | ||
|
|
3a95229ee9 | ||
|
|
5044e826ab | ||
|
|
4455a9259e | ||
|
|
717c20f74a | ||
|
|
72ead59eaa | ||
|
|
427100f11c | ||
|
|
e68a0b0ded | ||
|
|
8f69761b1e |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -239,6 +239,7 @@ async fn spawn_process_with_stdin_mode(
|
||||
exit_status,
|
||||
exit_code,
|
||||
/*pty_handles*/ None,
|
||||
/*resizer*/ None,
|
||||
);
|
||||
|
||||
Ok(SpawnedProcess {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
//! and elevated capture. The legacy restricted‑token 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;
|
||||
|
||||
@@ -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, ¤t_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));
|
||||
|
||||
@@ -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(¤t_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, ¤t_dir, env_map);
|
||||
let canonical_cwd = canonicalize_path(¤t_dir);
|
||||
|
||||
unsafe {
|
||||
for p in &allow {
|
||||
let psid = if is_command_cwd_root(p, &canonical_cwd) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
//! Low-level Windows thread attribute helpers used by ConPTY spawn.
|
||||
//!
|
||||
//! This module wraps the Win32 `PROC_THREAD_ATTRIBUTE_LIST` APIs so ConPTY handles can
|
||||
//! be attached to a child process. It is ConPTY‑specific and used in both legacy and
|
||||
//! elevated unified_exec paths when spawning a PTY‑backed process.
|
||||
|
||||
use std::io;
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::System::Threading::DeleteProcThreadAttributeList;
|
||||
use windows_sys::Win32::System::Threading::InitializeProcThreadAttributeList;
|
||||
use windows_sys::Win32::System::Threading::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 {
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
270
codex-rs/windows-sandbox-rs/src/spawn_prep.rs
Normal file
270
codex-rs/windows-sandbox-rs/src/spawn_prep.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
820
codex-rs/windows-sandbox-rs/src/unified_exec/session.rs
Normal file
820
codex-rs/windows-sandbox-rs/src/unified_exec/session.rs
Normal 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
|
||||
//! long‑lived `SpawnedProcess` wired for stdin/out/exit. It covers both the legacy
|
||||
//! restricted‑token path (direct spawn under a restricted token) and the elevated path
|
||||
//! (spawn via the command runner IPC). It is not used for non‑unified exec capture flows,
|
||||
//! which continue to use the one‑shot 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;
|
||||
519
codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs
Normal file
519
codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs
Normal 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:?}");
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user