diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 5b94daf252..66c79cd289 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -1,3 +1,4 @@ +use tree_sitter::Node; use tree_sitter::Parser; use tree_sitter::Tree; use tree_sitter_bash::LANGUAGE as BASH; @@ -75,7 +76,7 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option Option Option> { +/// Extract the plain words of a simple command node, normalizing quoted +/// strings into their contents. Returns None if the node contains unsupported +/// constructs for a word-only command. +pub(crate) fn extract_words_from_command_node(cmd: Node, src: &str) -> Option> { if cmd.kind() != "command" { return None; } @@ -130,6 +134,43 @@ fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option Option> { + let root = tree.root_node(); + let mut cursor = root.walk(); + let mut stack = vec![root]; + let mut best: Option = None; + while let Some(node) = stack.pop() { + if node.is_named() && node.kind() == "command" { + if best.map_or(true, |b| node.start_byte() < b.start_byte()) { + best = Some(node); + } + } + for child in node.children(&mut cursor) { + stack.push(child); + } + } + best +} + +/// Given the first command node, return the byte index in `src` at which the +/// remainder script starts, only if the next non-whitespace token is `&&`. +pub(crate) fn remainder_start_after_and(first_cmd: Node, src: &str) -> Option { + let mut sib = first_cmd.next_sibling()?; + while !sib.is_named() && sib.kind().trim().is_empty() { + sib = sib.next_sibling()?; + } + if sib.is_named() || sib.kind() != "&&" { + return None; + } + let mut idx = sib.end_byte() as usize; + let bytes = src.as_bytes(); + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + if idx >= bytes.len() { None } else { Some(idx) } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7177348f57..95d1e4dbe6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -11,6 +11,7 @@ use std::time::Duration; use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; use crate::event_mapping::map_response_item_to_event_messages; +use crate::prettify_command_for_display::prettify_command_for_display; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; @@ -2783,7 +2784,8 @@ async fn handle_container_exec_with_params( params.with_escalated_permissions.unwrap_or(false), ) }; - let command_for_display = params.command.clone(); + let command_for_display = prettify_command_for_display(¶ms.command) + .unwrap_or_else(|| params.command.clone()); (params, safety, command_for_display) } }; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 55d0062454..821fb465e0 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -36,6 +36,7 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +mod prettify_command_for_display; mod truncate; mod unified_exec; mod user_instructions; diff --git a/codex-rs/core/src/prettify_command_for_display.rs b/codex-rs/core/src/prettify_command_for_display.rs new file mode 100644 index 0000000000..00f23f4c51 --- /dev/null +++ b/codex-rs/core/src/prettify_command_for_display.rs @@ -0,0 +1,169 @@ +use crate::bash::extract_words_from_command_node; +use crate::bash::find_first_command_node; +use crate::bash::remainder_start_after_and; +use crate::bash::try_parse_bash; + +/// If one exists, returns a copy of `command` that reads more naturally in logs +/// and error messages. +/// +/// When the command is a classic shell wrapper such as `bash -lc "cd repo && +/// git status"`, the returned value contains only the prettified script (with +/// the leading `cd`/`pushd` removed) and excludes the `bash -lc` wrapper. +/// Any other command returns `None`. +pub fn prettify_command_for_display(command: &[String]) -> Option> { + let shell_script = parse_shell_script_from_shell_invocation(command)?; + + let tree = try_parse_bash(&shell_script)?; + + // Find the earliest command node in source order. + let first_cmd = find_first_command_node(&tree)?; + + // Verify the first command is `cd ` or `pushd ` (exactly 2 words). + let words = extract_words_from_command_node(first_cmd, &shell_script)?; + if !is_command_cd_to_directory(&words) { + return None; + } + + // Determine textual remainder using sibling tokens in the parse tree. + let idx = remainder_start_after_and(first_cmd, &shell_script)?; + let remainder = shell_script[idx..].to_string(); + Some(vec![remainder]) +} + +/// This is similar to [`crate::shell::strip_bash_lc`] and should potentially +/// be unified with it. +fn parse_shell_script_from_shell_invocation(command: &[String]) -> Option { + match command { + // exactly three items + [first, second, third] + // first two must be "bash", "-lc" + if first == "bash" && second == "-lc" => + { + Some(third.clone()) + } + _ => None, + } +} + +fn is_command_cd_to_directory(command: &[String]) -> bool { + matches!(command, [first, _dir] if first == "cd" || first == "pushd") +} + +// Helper moved to crate::bash + +#[cfg(test)] +mod tests { + use super::prettify_command_for_display; + use pretty_assertions::assert_eq; + + #[test] + fn cd_prefix_in_bash_script_is_hidden() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd foo && echo hi".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["echo hi".to_string()])); + } + + #[test] + fn cd_prefix_with_quoted_path_is_hidden() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + " cd 'foo bar' && ls".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["ls".to_string()])); + } + + #[test] + fn cd_prefix_with_additional_cd_is_preserved() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd foo && cd bar && ls".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["cd bar && ls".to_string()])); + } + + #[test] + fn cd_prefix_with_or_connector_is_preserved() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd and_and || echo \"couldn't find the dir for &&\"".to_string(), + ]; + let display = prettify_command_for_display(&command); + // Not a classic wrapper (uses ||), so no prettified form. + assert_eq!(display, None); + } + + #[test] + fn cd_prefix_preserves_operators_between_remaining_commands() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd foo && ls && git status".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["ls && git status".to_string()])); + } + + #[test] + fn cd_prefix_preserves_pipelines() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd foo && ls | rg foo".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["ls | rg foo".to_string()])); + } + + #[test] + fn cd_prefix_preserves_sequence_operator() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd foo && ls; git status".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["ls; git status".to_string()])); + } + + #[test] + fn non_shell_command_is_returned_unmodified() { + let command = vec!["rg".to_string(), "--files".to_string()]; + let display = prettify_command_for_display(&command); + // Not a shell wrapper, so no prettified form. + assert_eq!(display, None); + } + + #[test] + fn cd_prefix_with_or_operator_is_preserved() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd missing && ls || echo 'fallback'".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!(display, Some(vec!["ls || echo 'fallback'".to_string()])); + } + + #[test] + fn cd_prefix_with_ampersands_in_string_is_hidden() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cd foo && echo \"checking && markers\"".to_string(), + ]; + let display = prettify_command_for_display(&command); + assert_eq!( + display, + Some(vec!["echo \"checking && markers\"".to_string()]) + ); + } +}