Expose the PowerShell version in environment context (#41232)

## What changed

- Add the under-development `powershell_shell_version` feature flag.
- When enabled for a single local PowerShell environment, query the selected
  shell executable and include its major/minor version in
  `<environment_context>`.
- Cache version lookups, bound command execution and output, and report when a
  previously visible version becomes unavailable.

## Testing

- Cover environment-context diffs when the shell version appears or disappears.
- Verify on Windows that the version is model-visible only when the feature is
  enabled.

GitOrigin-RevId: 3ec8e80425ec3c193134dc1b69f61ef09ede375e
This commit is contained in:
zm-oai
2026-08-28 00:11:20 +00:00
committed by copyberry
parent 91c66024c7
commit dc031d4bc7
6 changed files with 188 additions and 2 deletions

View File

@@ -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"
},

View File

@@ -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<Mutex<BTreeMap<PathBuf, Option<String>>>> =
LazyLock::new(Mutex::default);
/// Environment values visible to the model.
#[derive(Clone, Debug, Default)]
pub(crate) struct EnvironmentsState {
environments: BTreeMap<String, EnvironmentState>,
shell_version: Option<String>,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
@@ -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<String>,
) -> 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 = &current.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<String, EnvironmentUpdate>,
legacy_single: bool,
include_primary: bool,
shell_version: Option<String>,
shell_version_removed: bool,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
@@ -243,6 +281,12 @@ impl ContextualUserFragment for RenderedEnvironments {
}
rendered.push_str(" </environments>\n");
}
if self.shell_version_removed {
rendered.push_str(" <shell_version status=\"unavailable\" />\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<String, EnvironmentSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
shell_version: Option<String>,
current_date: Option<String>,
timezone: Option<String>,
network: Option<String>,
@@ -345,6 +391,46 @@ enum EnvironmentStatus {
Available,
}
async fn powershell_version(shell_path: &Path) -> Option<String> {
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::<u16>().ok()?;
let minor = components.next()?.parse::<u16>().ok()?;
Some(format!("{major}.{minor}"))
});
POWERSHELL_VERSIONS
.lock()
.await
.insert(shell_path.to_owned(), version.clone());
version
}
fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap<String, EnvironmentState> {
let mut environments = snapshot
.turn_environments()

View File

@@ -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("<shell>powershell</shell>\n <shell_version>5.1</shell_version>"),
"{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(),
"<environment_context>\n <shell_version status=\"unavailable\" />\n</environment_context>"
);
}

View File

@@ -227,6 +227,7 @@ impl Session {
&step_context.environments,
Some(current_date),
)
.await
.with_subagents(environment_subagents),
);
}

View File

@@ -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",
"<shell>powershell</shell>"
));
assert_eq!(
message_input_text_contains(&request, "user", "<shell_version>5.1</shell_version>"),
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(()));

View File

@@ -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",