diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index b68a495..7c9cd71 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -80,7 +80,11 @@ fun parseToolInput(tool: String, input: String): ToolInput { ) } val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) - val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() } + val subject = + subjectKey + ?.let { json.optString(it) } + ?.takeIf { it.isNotBlank() } + ?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it } val description = DESCRIPTIONS.firstNotNullOfOrNull { json.optString(it).takeIf { v -> v.isNotBlank() } } @@ -98,6 +102,28 @@ fun parseToolInput(tool: String, input: String): ToolInput { return ToolInput(subject, language, description, timeout, rest) } +/** + * Removes Codex's rendered Bash argv from old transcript rows. + * + * New events arrive normalized by the server, but persisted transcripts keep the input originally + * written to them. Only the outer pair are presentation quoting: quotes inside the command belong + * to the command and must not be parsed as an early end delimiter. + */ +internal fun renderedBashScript(command: String): String? { + val prefix = + listOf("/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc ").firstOrNull { + command.startsWith(it) + } ?: return null + val quoted = command.removePrefix(prefix) + return quoted + .takeIf { + it.length >= 2 && + ((it.startsWith('\'') && it.endsWith('\'')) || + (it.startsWith('"') && it.endsWith('"'))) + } + ?.substring(1, quoted.lastIndex) +} + /** * A tool call's input: its subject highlighted, then whatever else it carried. * diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt new file mode 100644 index 0000000..9713335 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt @@ -0,0 +1,29 @@ +package com.example.aiapp + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ToolInputTest { + @Test + fun `bash wrapper ignores double quotes inside its outer pair`() { + assertEquals( + "rg -n \"needle\" server app", + renderedBashScript("/usr/bin/bash -lc \"rg -n \"needle\" server app\""), + ) + } + + @Test + fun `bash wrapper ignores single quotes inside its outer pair`() { + assertEquals( + "printf 'hello'", + renderedBashScript("/bin/bash -lc 'printf 'hello''"), + ) + } + + @Test + fun `unquoted or unfamiliar commands stay intact`() { + assertNull(renderedBashScript("/usr/bin/bash -lc echo hello")) + assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'")) + } +} diff --git a/server/src/session/codex/translate.rs b/server/src/session/codex/translate.rs index 78bb61a..7f89f3a 100644 --- a/server/src/session/codex/translate.rs +++ b/server/src/session/codex/translate.rs @@ -292,59 +292,19 @@ fn command_tool(item: &Value) -> (String, Value) { } /// App-server renders the executor argv into one shell-quoted string. Unwrap only an exact Bash -/// invocation containing one shell word; extra arguments stay visible rather than being decoded -/// into a command that was not actually run. +/// invocation whose script has matching outer quotes; extra arguments stay visible rather than +/// being mistaken for part of the script. fn rendered_bash_script(command: &str) -> Option { let quoted = ["/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc "] .iter() .find_map(|prefix| command.strip_prefix(prefix))?; - if quoted.starts_with('"') { - return double_quoted_shell_word(quoted); + if quoted.len() >= 2 + && ((quoted.starts_with('\'') && quoted.ends_with('\'')) + || (quoted.starts_with('"') && quoted.ends_with('"'))) + { + return Some(quoted[1..quoted.len() - 1].to_string()); } - if !quoted.starts_with('\'') { - return (!quoted.is_empty() && !quoted.chars().any(char::is_whitespace)) - .then(|| quoted.to_string()); - } - - let mut rest = quoted; - let mut script = String::new(); - loop { - rest = rest.strip_prefix('\'')?; - let end = rest.find('\'')?; - script.push_str(&rest[..end]); - rest = &rest[end + 1..]; - if rest.is_empty() { - return Some(script); - } - if let Some(after_quote) = rest.strip_prefix("\\'") { - script.push('\''); - rest = after_quote; - } else { - let after_quote = rest.strip_prefix("\"'\"")?; - script.push('\''); - rest = after_quote; - } - } -} - -fn double_quoted_shell_word(quoted: &str) -> Option { - let mut chars = quoted.strip_prefix('"')?.chars(); - let mut script = String::new(); - while let Some(character) = chars.next() { - match character { - '"' => return chars.next().is_none().then_some(script), - '\\' => match chars.next()? { - escaped @ ('$' | '`' | '"' | '\\') => script.push(escaped), - '\n' => {} - escaped => { - script.push('\\'); - script.push(escaped); - } - }, - character => script.push(character), - } - } - None + (!quoted.is_empty() && !quoted.chars().any(char::is_whitespace)).then(|| quoted.to_string()) } fn shell_word(word: &Value) -> String { @@ -599,21 +559,21 @@ mod tests { let rendered = tool(&json!({ "id": "rendered", "type": "commandExecution", - "command": r#"/usr/bin/bash -lc 'printf '\''%s\n'\'' hello'"# + "command": "/usr/bin/bash -lc 'printf \"%s\\n\" hello'" })); assert_eq!( rendered, Some(( "rendered".to_string(), "Bash".to_string(), - json!({"command": "printf '%s\\n' hello"}) + json!({"command": "printf \"%s\\n\" hello"}) )) ); let double_quoted = tool(&json!({ "id": "double-quoted", "type": "commandExecution", - "command": r#"/usr/bin/bash -lc "printf '%s\\n' \"\$HOME\" \\path""# + "command": r#"/usr/bin/bash -lc "printf '%s\n' "$HOME" \path""# })); assert_eq!( double_quoted,