diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 19484e26f1..abed992767 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -817,6 +817,9 @@ "plugins": { "type": "boolean" }, + "powershell_shell_version": { + "type": "boolean" + }, "prevent_idle_sleep": { "type": "boolean" }, @@ -5970,6 +5973,9 @@ "plugins": { "type": "boolean" }, + "powershell_shell_version": { + "type": "boolean" + }, "prevent_idle_sleep": { "type": "boolean" }, diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs index 7a1edd68c6..93080b9c58 100644 --- a/codex-rs/core/src/context/world_state/environment.rs +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -6,16 +6,29 @@ use crate::context::environment_context::NetworkContext; use crate::context::environment_context::push_xml_escaped_text; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::session::turn_context::TurnContext; +use crate::shell::ShellType; +use codex_features::Feature; use codex_protocol::models::ContentItemKind; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::LazyLock; +use std::time::Duration; +use tokio::process::Command; +use tokio::sync::Mutex; + +static POWERSHELL_VERSIONS: LazyLock>>> = + LazyLock::new(Mutex::default); /// Environment values visible to the model. #[derive(Clone, Debug, Default)] pub(crate) struct EnvironmentsState { environments: BTreeMap, + shell_version: Option, current_date: Option, timezone: Option, network: Option, @@ -24,13 +37,26 @@ pub(crate) struct EnvironmentsState { } impl EnvironmentsState { - pub(crate) fn from_turn_context_with_environments( + pub(crate) async fn from_turn_context_with_environments( turn_context: &TurnContext, environments: &TurnEnvironmentSnapshot, current_date: Option, ) -> Self { + let shell_version = if turn_context + .config + .features + .enabled(Feature::PowerShellShellVersion) + && let Some(environment) = environments.single_local_environment() + && let Some(shell) = environment.shell.as_ref() + && shell.shell_type == ShellType::PowerShell + { + powershell_version(&shell.shell_path).await + } else { + None + }; Self { environments: environment_states(environments), + shell_version, current_date, timezone: turn_context.timezone.clone(), network: network_from_turn_context(turn_context), @@ -62,6 +88,8 @@ impl EnvironmentsState { .collect(), legacy_single: is_legacy_single(&self.environments), include_primary: self.environments.len() > 1, + shell_version: self.shell_version.clone(), + shell_version_removed: false, current_date: self.current_date.clone(), timezone: self.timezone.clone(), network: self.network.clone(), @@ -92,6 +120,7 @@ impl WorldStateSection for EnvironmentsState { ) }) .collect(), + shell_version: self.shell_version.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), network: self.network.as_ref().map(NetworkContext::render), @@ -110,7 +139,10 @@ impl WorldStateSection for EnvironmentsState { PreviousSectionState::Known(previous) => previous, PreviousSectionState::Absent | PreviousSectionState::Unknown => &empty, }; - let turn_context_values_changed = current.current_date != previous.current_date + let shell_version_added = + current.shell_version.is_some() && previous.shell_version.is_none(); + let turn_context_values_changed = current.shell_version != previous.shell_version + || current.current_date != previous.current_date || current.timezone != previous.timezone || current.network != previous.network || current.filesystem != previous.filesystem; @@ -123,6 +155,7 @@ impl WorldStateSection for EnvironmentsState { let environment = ¤t.environments[*id]; previous.environments.get(*id).is_none_or(|previous| { multiple_environments != previous_multiple_environments + || (shell_version_added && previous.shell.is_none()) || !environment.has_same_diff_value(previous) }) }) @@ -144,6 +177,9 @@ impl WorldStateSection for EnvironmentsState { updates, legacy_single, include_primary: multiple_environments || previous_multiple_environments, + shell_version: self.shell_version.clone(), + shell_version_removed: self.shell_version.is_none() + && previous.shell_version.is_some(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), network: self.network.clone(), @@ -180,6 +216,8 @@ struct RenderedEnvironments { updates: BTreeMap, legacy_single: bool, include_primary: bool, + shell_version: Option, + shell_version_removed: bool, current_date: Option, timezone: Option, network: Option, @@ -243,6 +281,12 @@ impl ContextualUserFragment for RenderedEnvironments { } rendered.push_str(" \n"); } + if self.shell_version_removed { + rendered.push_str(" \n"); + } else { + let shell_version = self.shell_version.as_deref(); + push_optional_element(&mut rendered, "shell_version", shell_version); + } push_optional_element(&mut rendered, "current_date", self.current_date.as_deref()); push_optional_element(&mut rendered, "timezone", self.timezone.as_deref()); if let Some(network) = &self.network { @@ -309,6 +353,8 @@ struct EnvironmentState { #[derive(Default, Deserialize, Serialize)] pub(crate) struct EnvironmentsSnapshot { environments: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + shell_version: Option, current_date: Option, timezone: Option, network: Option, @@ -345,6 +391,46 @@ enum EnvironmentStatus { Available, } +async fn powershell_version(shell_path: &Path) -> Option { + if let Some(version) = { + let versions = POWERSHELL_VERSIONS.lock().await; + versions.get(shell_path).cloned() + } { + return version; + } + + let mut command = Command::new(shell_path); + command + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + + let version = tokio::time::timeout(Duration::from_secs(2), command.output()) + .await + .ok() + .and_then(Result::ok) + .filter(|output| output.status.success() && output.stdout.len() <= 64) + .and_then(|output| { + let mut components = std::str::from_utf8(&output.stdout).ok()?.trim().split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next()?.parse::().ok()?; + Some(format!("{major}.{minor}")) + }); + POWERSHELL_VERSIONS + .lock() + .await + .insert(shell_path.to_owned(), version.clone()); + version +} + fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap { let mut environments = snapshot .turn_environments() diff --git a/codex-rs/core/src/context/world_state/environment_render_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs index 5cece76b12..cd26421669 100644 --- a/codex-rs/core/src/context/world_state/environment_render_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -57,6 +57,7 @@ fn environment_state( .collect(); EnvironmentsState { environments, + shell_version: None, current_date, timezone, network, @@ -353,3 +354,44 @@ fn serialize_environment_context_prefers_environment_shell_when_present() { assert_eq!(context.render(), expected); } + +fn powershell_environment() -> EnvironmentsState { + let cwd = PathUri::from_abs_path(&test_abs_path("/repo")); + EnvironmentsState { + environments: [environment("local", cwd, "powershell")].into(), + shell_version: Some("5.1".to_string()), + ..Default::default() + } +} + +#[test] +fn shell_version_diff_restates_shell_from_legacy_snapshot() { + let current = powershell_environment(); + let mut previous = current.snapshot(); + previous.shell_version = None; + previous.environments.get_mut("local").expect("local").shell = None; + let rendered = current + .render_diff(PreviousSectionState::Known(&previous)) + .expect("shell version update") + .render(); + assert!( + rendered.contains("powershell\n 5.1"), + "{rendered}" + ); +} + +#[test] +fn shell_version_diff_clears_previously_visible_version() { + let previous = powershell_environment(); + let current = EnvironmentsState { + shell_version: None, + ..previous.clone() + }; + assert_eq!( + current + .render_diff(PreviousSectionState::Known(&previous.snapshot())) + .expect("removed shell version") + .render(), + "\n \n" + ); +} diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 219a0c5345..740665bfa7 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -227,6 +227,7 @@ impl Session { &step_context.environments, Some(current_date), ) + .await .with_subagents(environment_subagents), ); } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 556c3844f2..de409cc4ec 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2212,6 +2212,49 @@ async fn omits_environment_context_when_configured_off() { ); } +#[cfg(windows)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn powershell_shell_version_is_model_visible_only_when_enabled() -> anyhow::Result<()> { + core_test_support::skip_if_remote!(Ok(()), "requires local Windows PowerShell execution"); + + let shell_path = codex_shell_command::powershell::try_find_powershell_executable_blocking() + .ok_or_else(|| anyhow::anyhow!("Windows PowerShell is unavailable"))? + .to_path_buf(); + for enabled in [false, true] { + let server = MockServer::start().await; + let response = mount_sse_once(&server, sse(vec![ev_completed("done")])).await; + let user_shell = codex_shell_command::shell_detect::DetectedShell { + shell_type: codex_shell_command::shell_detect::ShellType::PowerShell, + shell_path: shell_path.clone(), + } + .into(); + let mut builder = test_codex() + .with_user_shell(user_shell) + .with_config(move |config| { + config + .features + .set_enabled(Feature::PowerShellShellVersion, enabled) + .expect("test config should allow PowerShell version feature updates"); + }); + let test = builder.build_with_auto_env(&server).await?; + test.submit_turn("report the selected shell").await?; + + let request = response.single_request(); + assert!(message_input_text_contains( + &request, + "user", + "powershell" + )); + assert_eq!( + message_input_text_contains(&request, "user", "5.1"), + enabled, + "PowerShell shell version must follow its feature flag" + ); + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_configured_max_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index a4d275cac8..04a5b86b53 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -153,6 +153,8 @@ pub enum Feature { UseLegacyLandlock, /// Experimental shell snapshotting. ShellSnapshot, + /// Expose the selected PowerShell execution host's bounded major/minor version. + PowerShellShellVersion, /// Keep policy-filtered shell snapshots entirely in executor memory. ShellSnapshotV2, /// Allow turns to start while selected executors are still starting. @@ -901,6 +903,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::PowerShellShellVersion, + key: "powershell_shell_version", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ShellSnapshotV2, key: "shell_snapshot_v2",