From 662eb6356dc1dcb64ec3da02a61ecbd96b3288f1 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sun, 14 Jun 2026 08:59:27 +0000 Subject: [PATCH] shell-command: parse literal PowerShell reads --- .../remote_env_windows_test.rs | 18 +++-- codex-rs/shell-command/src/parse_command.rs | 73 ++++++++++++++++--- codex-rs/shell-command/src/powershell.rs | 62 ++++++++++++++++ 3 files changed, 139 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 69bdee4d39..5e9eaa365e 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -47,6 +47,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::Value; @@ -188,7 +189,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; const CALL_ID: &str = "wine-cmd-smoke"; - const COMMAND: &str = "Get-Content AGENTS.md -ErrorAction Stop"; + const COMMAND: &str = "Get-Content 'AGENTS.md' -ErrorAction Stop"; const NATIVE_CWD: &str = r"C:\windows"; WineExecServer @@ -332,10 +333,17 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul }; assert_eq!(id, CALL_ID); assert_eq!(cwd.as_str(), r"C:\windows"); - // TODO(anp): Parse command actions using the selected environment's path convention so - // their paths remain Windows-native instead of degrading the action to Unknown. - assert_eq!(command_actions.len(), 1); - assert!(matches!(command_actions[0], CommandAction::Unknown { .. })); + assert_eq!( + command_actions, + vec![CommandAction::Read { + command: COMMAND.to_string(), + name: "AGENTS.md".to_string(), + path: ApiPathString::from_native_absolute_path( + r"C:\windows\AGENTS.md", + PathConvention::Windows, + )?, + }] + ); assert_eq!((status, exit_code), (CommandExecutionStatus::Completed, Some(0))); timeout( APP_SERVER_READ_TIMEOUT, diff --git a/codex-rs/shell-command/src/parse_command.rs b/codex-rs/shell-command/src/parse_command.rs index 72e1aca67d..7133aa2fb9 100644 --- a/codex-rs/shell-command/src/parse_command.rs +++ b/codex-rs/shell-command/src/parse_command.rs @@ -2,6 +2,7 @@ use crate::bash::extract_bash_command; use crate::bash::try_parse_shell; use crate::bash::try_parse_word_only_commands_sequence; use crate::powershell::extract_powershell_command; +use crate::powershell::extract_powershell_literal_read_path; use codex_protocol::parse_command::ParsedCommand; use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; @@ -1257,19 +1258,68 @@ mod tests { #[test] fn powershell_with_path_is_stripped() { - let command = if cfg!(windows) { - "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - } else { - "/usr/local/bin/powershell.exe" - }; - assert_parsed( - &vec_str(&[command, "-NoProfile", "-c", "Write-Host hi"]), + &vec_str(&[ + r"C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-NoProfile", + "-c", + "Write-Host hi", + ]), vec![ParsedCommand::Unknown { cmd: "Write-Host hi".to_string(), }], ); } + + #[test] + fn powershell_literal_get_content_is_read_without_the_host_shell() { + let script = r"Get-Content 'C:\codex runtime\AGENTS.md' -ErrorAction Stop"; + assert_parsed( + &vec_str(&[ + r"C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-NoProfile", + "-Command", + script, + ]), + vec![ParsedCommand::Read { + cmd: script.to_string(), + name: "AGENTS.md".to_string(), + path: PathBuf::from(r"C:\codex runtime\AGENTS.md"), + }], + ); + + let alias_script = r"gc '.\input.txt'"; + assert_parsed( + &vec_str(&["pwsh.exe", "-Command", alias_script]), + vec![ParsedCommand::Read { + cmd: alias_script.to_string(), + name: "input.txt".to_string(), + path: PathBuf::from(r".\input.txt"), + }], + ); + } + + #[test] + fn powershell_dynamic_or_compound_get_content_stays_unknown() { + for script in [ + "Get-Content $path", + r#"Get-Content "$(Get-Location)\AGENTS.md""#, + "Get-Content AGENTS.md | Select-Object -First 1", + "Get-Content AGENTS.md; Remove-Item AGENTS.md", + "Get-Content *.md", + "Get-Content Env:PATH", + r"'Get-Content' 'C:\input.txt'", + r"Get-Content '-Path' 'C:\input.txt'", + r#"Get-Content "C:\input.txt""#, + ] { + assert_parsed( + &vec_str(&["powershell.exe", "-Command", script]), + vec![ParsedCommand::Unknown { + cmd: script.to_string(), + }], + ); + } + } } pub fn parse_command_impl(command: &[String]) -> Vec { @@ -1278,9 +1328,14 @@ pub fn parse_command_impl(command: &[String]) -> Vec { } if let Some((_, script)) = extract_powershell_command(command) { - return vec![ParsedCommand::Unknown { + let parsed = extract_powershell_literal_read_path(script).map(|path| ParsedCommand::Read { cmd: script.to_string(), - }]; + name: short_display_path(path), + path: PathBuf::from(path), + }); + return vec![parsed.unwrap_or_else(|| ParsedCommand::Unknown { + cmd: script.to_string(), + })]; } let normalized = normalize_tokens(command); diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index 9730439bea..8b62a14e65 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -70,6 +70,68 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { None } +/// Extracts the literal path from the narrow `Get-Content` shape that can be recognized without +/// launching the selected execution environment's PowerShell on the Codex host. +pub(crate) fn extract_powershell_literal_read_path(script: &str) -> Option<&str> { + if script + .chars() + .any(|character| matches!(character, '\0' | '\r' | '\n')) + { + return None; + } + + let script = script.trim(); + let command_end = script.find(char::is_whitespace)?; + let command = &script[..command_end]; + if !command.eq_ignore_ascii_case("Get-Content") && !command.eq_ignore_ascii_case("gc") { + return None; + } + + let quoted_path = script[command_end..].trim_start().strip_prefix('\'')?; + let path_end = quoted_path.find('\'')?; + let path = "ed_path[..path_end]; + if path.is_empty() + || path + .chars() + .any(|character| matches!(character, '*' | '?' | '[' | ']')) + || is_powershell_provider_path(path) + { + return None; + } + + let trailing = quoted_path[path_end + 1..].trim(); + if !trailing.is_empty() { + let mut words = trailing.split_whitespace(); + let flag = words.next()?; + let value = words.next()?; + if !flag.eq_ignore_ascii_case("-ErrorAction") + || !value.eq_ignore_ascii_case("Stop") + || words.next().is_some() + { + return None; + } + } + + Some(path) +} + +fn is_powershell_provider_path(path: &str) -> bool { + if path.contains("::") { + return true; + } + + let bytes = path.as_bytes(); + match bytes.iter().position(|byte| *byte == b':') { + None => false, + Some(1) => !matches!( + bytes, + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && matches!(separator, b'\\' | b'/') + ), + Some(_) => true, + } +} + /// Parse the script body from a top-level PowerShell wrapper into argv-like commands. /// /// This is intentionally narrower than the Windows safe-command parser: it only unwraps the