mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
shell-command: parse literal PowerShell reads
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user