diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 79a2e1c066..8bf0845d1a 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -1003,6 +1003,15 @@ async fn main() -> Result<(), Box> { std::fs::write(pid_file, std::process::id().to_string())?; } #[cfg(windows)] + if let Ok(console_state_file) = std::env::var("MCP_TEST_CONSOLE_STATE_FILE") { + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetConsoleWindow() -> *mut std::ffi::c_void; + } + let has_console = unsafe { !GetConsoleWindow().is_null() }; + std::fs::write(console_state_file, has_console.to_string())?; + } + #[cfg(windows)] if let Ok(marker_file) = std::env::var("MCP_TEST_BREAKAWAY_DENIED_FILE") { use std::os::windows::process::CommandExt; diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 433f532a13..13fabc46d9 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -214,6 +214,11 @@ impl StdioServerLauncher for LocalStdioServerLauncher { // Local private implementation. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] +const CREATE_SUSPENDED: u32 = 0x0000_0004; + #[cfg(unix)] const PROCESS_GROUP_TERM_GRACE_PERIOD: Duration = Duration::from_secs(2); @@ -288,6 +293,8 @@ impl LocalStdioServerLauncher { // handles and needs a handle allowlist in the shared spawn backend. #[cfg(unix)] command.descriptor_policy(DescriptorPolicy::Explicit); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); command }; #[cfg(windows)] @@ -298,6 +305,8 @@ impl LocalStdioServerLauncher { let job = match codex_utils_pty::JobObject::create_without_breakaway() { Ok(job) => { command.prepare_suspended_spawn(&job); + // The helper replaces creation flags; retain suspension and suppress the console. + command.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW); Some(job) } Err(error) => { diff --git a/codex-rs/rmcp-client/tests/stdio_message_limits.rs b/codex-rs/rmcp-client/tests/stdio_message_limits.rs index 7c8ec6102b..cbfeceae4b 100644 --- a/codex-rs/rmcp-client/tests/stdio_message_limits.rs +++ b/codex-rs/rmcp-client/tests/stdio_message_limits.rs @@ -134,6 +134,90 @@ async fn stdio_message_limits_preserve_legacy_local_compatibility() -> anyhow::R Ok(()) } +#[cfg(windows)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_stdio_initializes_without_a_console_directly_and_through_cmd() -> anyhow::Result<()> +{ + let server = codex_utils_cargo_bin::cargo_bin("test_stdio_server")?; + let temp_dir = tempfile::tempdir()?; + let console_state_file = temp_dir.path().join("console-state"); + let server_pid_file = temp_dir.path().join("server.pid"); + std::fs::write( + temp_dir.path().join("launch.cmd"), + "@echo off\r\n\"%MCP_TEST_SERVER_BINARY%\"\r\n", + )?; + let env = HashMap::from([ + ( + OsString::from("MCP_TEST_CONSOLE_STATE_FILE"), + console_state_file.clone().into_os_string(), + ), + ( + OsString::from("MCP_TEST_PID_FILE"), + server_pid_file.clone().into_os_string(), + ), + ( + OsString::from("MCP_TEST_SERVER_BINARY"), + server.clone().into(), + ), + ]); + + for (program, args) in [ + (server.into(), Vec::new()), + ( + OsString::from("cmd.exe"), + ["/d", "/s", "/c", "call", ".\\launch.cmd"] + .map(OsString::from) + .to_vec(), + ), + ] { + let client = RmcpClient::new_stdio_client( + program, + args, + Some(env.clone()), + &[], + Some(temp_dir.path().to_string_lossy().into_owned()), + Arc::new(LocalStdioServerLauncher::new(temp_dir.path().to_path_buf())), + ) + .await?; + client + .initialize( + InitializeRequestParams::new( + ClientCapabilities::default(), + Implementation::new("stdio-console-test", "1.0.0"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18), + Some(Duration::from_secs(10)), + Box::new(|_, _| { + async { + Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }) + } + .boxed() + }), + ) + .await?; + let console_state = std::fs::read_to_string(&console_state_file)?; + let server_pid = std::fs::read_to_string(&server_pid_file)? + .trim() + .parse::()?; + let server_process = open_process_for_wait(server_pid)?; + let tools = client + .list_tools(/*params*/ None, Some(Duration::from_secs(10))) + .await; + client.shutdown().await; + wait_for_process_exit(&server_process)?; + + pretty_assertions::assert_eq!(console_state, "false"); + assert!(!tools?.tools.is_empty()); + std::fs::remove_file(&console_state_file)?; + std::fs::remove_file(&server_pid_file)?; + } + Ok(()) +} + #[cfg(windows)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn local_stdio_shutdown_terminates_descendants_after_server_exit() -> anyhow::Result<()> { diff --git a/codex-rs/utils/pty/src/child_command.rs b/codex-rs/utils/pty/src/child_command.rs index c245653a95..0bdc5362a6 100644 --- a/codex-rs/utils/pty/src/child_command.rs +++ b/codex-rs/utils/pty/src/child_command.rs @@ -233,6 +233,12 @@ impl Command { self } + /// Set the Windows process creation flags, replacing any previously selected flags. + #[cfg(windows)] + pub fn creation_flags(&mut self, flags: u32) -> &mut Self { + self.inner.creation_flags(flags); + self + } /// Preserve Job Object assignment before the child begins executing on Windows. #[cfg(windows)] pub fn prepare_suspended_spawn(&mut self, job: &crate::JobObject) {