mirror of
https://github.com/openai/codex.git
synced 2026-09-09 15:58:47 +00:00
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 = ¤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<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()
|
||||
|
||||
@@ -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>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ impl Session {
|
||||
&step_context.environments,
|
||||
Some(current_date),
|
||||
)
|
||||
.await
|
||||
.with_subagents(environment_subagents),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user