shell-command: parse literal PowerShell reads

This commit is contained in:
Adam Perry
2026-06-14 08:59:27 +00:00
parent 3bb5505847
commit 662eb6356d
3 changed files with 139 additions and 14 deletions

View File

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

View File

@@ -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<ParsedCommand> {
@@ -1278,9 +1328,14 @@ pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
}
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);

View File

@@ -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 = &quoted_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