diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index af1f0e9603..b6c3241307 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -46,6 +46,7 @@ time = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", + "process", "rt-multi-thread", ] } tokio-tungstenite = { workspace = true } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/host_device.rs b/codex-rs/app-server-transport/src/transport/remote_control/host_device.rs new file mode 100644 index 0000000000..02f9d9fd43 --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/host_device.rs @@ -0,0 +1,74 @@ +#[cfg(any(target_os = "macos", test))] +use serde::Deserialize; + +pub(super) const REMOTE_CONTROL_HOST_DEVICE_KIND_HEADER: &str = "x-codex-host-device-kind"; +#[cfg(any(target_os = "macos", test))] +const MAC_MINI_HOST_DEVICE_KIND: &str = "mac_mini"; + +#[cfg(any(target_os = "macos", test))] +#[derive(Deserialize)] +struct MacHardwareProfile { + #[serde(rename = "SPHardwareDataType")] + hardware: Vec, +} + +#[cfg(any(target_os = "macos", test))] +#[derive(Deserialize)] +struct MacHardware { + machine_name: String, +} + +#[cfg(any(target_os = "macos", test))] +fn host_device_kind_from_profile(profile: &[u8]) -> serde_json::Result> { + let profile: MacHardwareProfile = serde_json::from_slice(profile)?; + Ok(profile + .hardware + .first() + .is_some_and(|hardware| hardware.machine_name == "Mac mini") + .then_some(MAC_MINI_HOST_DEVICE_KIND)) +} + +#[cfg(target_os = "macos")] +pub(super) async fn host_device_kind() -> Option<&'static str> { + use std::process::Stdio; + use std::time::Duration; + use tokio::process::Command; + use tokio::sync::OnceCell; + + static HOST_DEVICE_KIND: OnceCell> = OnceCell::const_new(); + + HOST_DEVICE_KIND + .get_or_try_init(|| async { + let output = tokio::time::timeout( + Duration::from_secs(2), + Command::new("/usr/sbin/system_profiler") + .args(["-detailLevel", "mini", "SPHardwareDataType", "-json"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + + if !output.status.success() { + return Err(()); + } + + host_device_kind_from_profile(&output.stdout).map_err(|_| ()) + }) + .await + .ok() + .copied() + .flatten() +} + +#[cfg(not(target_os = "macos"))] +pub(super) async fn host_device_kind() -> Option<&'static str> { + None +} + +#[cfg(test)] +#[path = "host_device_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/host_device_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/host_device_tests.rs new file mode 100644 index 0000000000..4c02b2111c --- /dev/null +++ b/codex-rs/app-server-transport/src/transport/remote_control/host_device_tests.rs @@ -0,0 +1,38 @@ +use super::host_device_kind_from_profile; +use pretty_assertions::assert_eq; + +#[test] +fn recognizes_only_the_exact_mac_mini_hardware_name() { + let mac_mini_profile = br#"{"SPHardwareDataType":[{"machine_name":"Mac mini"}]}"#; + let macbook_profile = br#"{"SPHardwareDataType":[{"machine_name":"MacBook Pro"}]}"#; + let misleading_profile = br#"{"SPHardwareDataType":[{"machine_name":"Not a Mac mini"}]}"#; + + assert_eq!( + host_device_kind_from_profile(mac_mini_profile).expect("valid Mac mini hardware profile"), + Some("mac_mini") + ); + assert_eq!( + host_device_kind_from_profile(macbook_profile).expect("valid MacBook hardware profile"), + None + ); + assert_eq!( + host_device_kind_from_profile(misleading_profile) + .expect("valid non-Mac-mini hardware profile"), + None + ); +} + +#[test] +fn ignores_missing_hardware_profiles() { + assert_eq!( + host_device_kind_from_profile(br#"{"SPHardwareDataType":[]}"#) + .expect("valid empty hardware profile"), + None + ); +} + +#[test] +fn rejects_malformed_hardware_profiles_so_they_remain_retryable() { + assert!(host_device_kind_from_profile(br#"{"machine_name":"Mac mini"}"#).is_err()); + assert!(host_device_kind_from_profile(b"not json").is_err()); +} diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 7422c92c42..a9a7814f4f 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -3,6 +3,7 @@ mod client_tracker; mod clients; mod desired_state; mod enroll; +mod host_device; mod protocol; mod segment; mod server_api; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 84f5affe6d..e8e30e9139 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -27,6 +27,8 @@ use crate::transport::remote_control::enroll::format_headers; use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment; use crate::transport::remote_control::enroll::preview_remote_control_response_body; use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; +use crate::transport::remote_control::host_device::REMOTE_CONTROL_HOST_DEVICE_KIND_HEADER; +use crate::transport::remote_control::host_device::host_device_kind; use crate::transport::remote_control::server_api::enroll_remote_control_server; use crate::transport::remote_control::server_api::refresh_remote_control_server; use axum::http::HeaderValue; @@ -1250,7 +1252,7 @@ fn set_remote_control_header( Ok(()) } -fn build_remote_control_websocket_request( +async fn build_remote_control_websocket_request( websocket_url: &str, enrollment: &RemoteControlEnrollment, installation_id: &str, @@ -1290,6 +1292,13 @@ fn build_remote_control_websocket_request( REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id, )?; + if let Some(host_device_kind) = host_device_kind().await { + set_remote_control_header( + headers, + REMOTE_CONTROL_HOST_DEVICE_KIND_HEADER, + host_device_kind, + )?; + } if let Some(subscribe_cursor) = subscribe_cursor { set_remote_control_header( headers, @@ -1345,7 +1354,8 @@ pub(super) async fn connect_remote_control_websocket( &enrollment, connect_options.installation_id, connect_options.subscribe_cursor, - )?; + ) + .await?; let websocket_connect_result = tokio::time::timeout( REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT,