diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d2a9c2babd..b4aa7f49f1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5115,6 +5115,7 @@ dependencies = [ "portable-pty", "pretty_assertions", "shared_library", + "tempfile", "tokio", "winapi", ] diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 51d94292c8..51105cf535 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -12,7 +12,6 @@ mod http_client_adapter; mod http_client_redirect; mod http_headers; mod in_process_transport; -mod local_child; mod local_stdio_transport; mod logging_client_handler; mod oauth; diff --git a/codex-rs/rmcp-client/src/local_stdio_transport.rs b/codex-rs/rmcp-client/src/local_stdio_transport.rs index b94c42584b..840425e36e 100644 --- a/codex-rs/rmcp-client/src/local_stdio_transport.rs +++ b/codex-rs/rmcp-client/src/local_stdio_transport.rs @@ -18,12 +18,11 @@ use tokio::process::ChildStdout; use tokio::process::Command; use crate::bounded_stdio_transport::BoundedStdioTransport; -use crate::local_child; -use crate::local_child::LocalChild; use crate::protocol_mode::McpProtocolMode; +use codex_utils_pty::Child; pub(super) struct LocalStdioTransport { - child: LocalChild, + child: Child, transport: StdioTransport, } @@ -40,7 +39,7 @@ impl LocalStdioTransport { program_name: String, protocol_mode: McpProtocolMode, ) -> io::Result<(Self, Option)> { - let mut child = local_child::spawn(command)?; + let mut child = codex_utils_pty::spawn_child(command)?; let stdin = child .stdin .take() diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index 2a27ab72ea..69942817ca 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -10,9 +10,10 @@ workspace = true [dependencies] anyhow = { workspace = true } portable-pty = { workspace = true } -tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } +tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } [dev-dependencies] +tempfile = { workspace = true } pretty_assertions = { workspace = true } [target.'cfg(windows)'.dependencies] diff --git a/codex-rs/rmcp-client/src/local_child.rs b/codex-rs/utils/pty/src/child.rs similarity index 66% rename from codex-rs/rmcp-client/src/local_child.rs rename to codex-rs/utils/pty/src/child.rs index fdb98303e5..bc24665cb4 100644 --- a/codex-rs/rmcp-client/src/local_child.rs +++ b/codex-rs/utils/pty/src/child.rs @@ -1,4 +1,4 @@ -//! Uniform child-process API for local MCP servers, with platform-specific spawning. +//! Uniform child-process API for local subprocesses, with platform-specific spawning. //! //! Commands must use the launcher's cleared environment, process group, and default //! argv[0]. Both implementations expose Tokio stdio handles and kill on drop. @@ -9,15 +9,15 @@ use std::process::Stdio; use tokio::process::Command; #[cfg(target_os = "macos")] -#[path = "macos_stdio.rs"] +#[path = "macos_child.rs"] mod macos; #[cfg(target_os = "macos")] -pub(super) use macos::LocalChild; +pub use macos::Child; #[cfg(not(target_os = "macos"))] -pub(super) use tokio::process::Child as LocalChild; +pub use tokio::process::Child; -pub(super) fn spawn(mut command: Command) -> io::Result { +pub fn spawn(mut command: Command) -> io::Result { command .kill_on_drop(true) .stdin(Stdio::piped()) @@ -25,7 +25,7 @@ pub(super) fn spawn(mut command: Command) -> io::Result { .stderr(Stdio::piped()); #[cfg(target_os = "macos")] { - LocalChild::spawn(command) + Child::spawn(command) } #[cfg(not(target_os = "macos"))] { diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 1200c11438..1587729da6 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -1,3 +1,6 @@ +mod child; +pub use child::Child; +pub use child::spawn as spawn_child; pub mod pipe; mod process; pub mod process_group; diff --git a/codex-rs/rmcp-client/src/macos_stdio.rs b/codex-rs/utils/pty/src/macos_child.rs similarity index 97% rename from codex-rs/rmcp-client/src/macos_stdio.rs rename to codex-rs/utils/pty/src/macos_child.rs index af471320f3..7ed689ee99 100644 --- a/codex-rs/rmcp-client/src/macos_stdio.rs +++ b/codex-rs/utils/pty/src/macos_child.rs @@ -19,7 +19,7 @@ use std::path::Path; use std::process::ExitStatus; use std::ptr; -use tokio::process::Child; +use tokio::process::Child as TokioChild; use tokio::process::ChildStderr; use tokio::process::ChildStdin; use tokio::process::ChildStdout; @@ -29,19 +29,19 @@ use tokio::signal::unix::SignalKind; use tokio::signal::unix::signal; /// Matches Tokio's child API while keeping native spawning private to macOS. -pub(crate) struct LocalChild { +pub struct Child { inner: ChildKind, - pub(crate) stdin: Option, - pub(crate) stdout: Option, - pub(crate) stderr: Option, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, } enum ChildKind { - Tokio(Child), + Tokio(TokioChild), Native(NativeChild), } -impl LocalChild { +impl Child { /// Uses native spawning for relative paths and bare names, retaining Tokio's /// fallback for unsuccessful PATH searches and executable text without a shebang. pub(super) fn spawn(mut command: Command) -> io::Result { @@ -66,14 +66,14 @@ impl LocalChild { }) } - pub(crate) fn id(&self) -> Option { + pub fn id(&self) -> Option { match &self.inner { ChildKind::Tokio(child) => child.id(), ChildKind::Native(child) => child.id(), } } - pub(crate) async fn wait(&mut self) -> io::Result { + pub async fn wait(&mut self) -> io::Result { self.stdin.take(); match &mut self.inner { ChildKind::Tokio(child) => child.wait().await, @@ -81,7 +81,7 @@ impl LocalChild { } } - pub(crate) async fn kill(&mut self) -> io::Result<()> { + pub async fn kill(&mut self) -> io::Result<()> { self.stdin.take(); match &mut self.inner { ChildKind::Tokio(child) => child.kill().await, @@ -411,5 +411,5 @@ impl Drop for Attributes { } #[cfg(test)] -#[path = "macos_stdio_tests.rs"] +#[path = "macos_child_tests.rs"] mod tests; diff --git a/codex-rs/rmcp-client/src/macos_stdio_tests.rs b/codex-rs/utils/pty/src/macos_child_tests.rs similarity index 98% rename from codex-rs/rmcp-client/src/macos_stdio_tests.rs rename to codex-rs/utils/pty/src/macos_child_tests.rs index 4bf9ed0634..39b17e0157 100644 --- a/codex-rs/rmcp-client/src/macos_stdio_tests.rs +++ b/codex-rs/utils/pty/src/macos_child_tests.rs @@ -11,7 +11,7 @@ use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; async fn native_output(command: Command) -> anyhow::Result { - let mut child = crate::local_child::spawn(command)?; + let mut child = crate::spawn_child(command)?; assert!(matches!(child.inner, ChildKind::Native(_))); drop(child.stdin.take()); let mut stdout = child.stdout.take().expect("piped stdout"); @@ -213,7 +213,7 @@ async fn launch_failures_preserve_os_errors() -> anyhow::Result<()> { .env_clear() .env("PATH", root.path()) .current_dir(cwd); - let error = crate::local_child::spawn(command) + let error = crate::spawn_child(command) .err() .expect("spawn should fail"); assert_eq!(error.raw_os_error(), Some(errno)); @@ -233,7 +233,7 @@ async fn executable_text_without_shebang_retains_command_fallback() -> anyhow::R .current_dir(root.path()) .env_clear() .env("PATH", "."); - let mut child = crate::local_child::spawn(command)?; + let mut child = crate::spawn_child(command)?; let mut output = Vec::new(); child .stdout