mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: handle multiline quoted strings in command auto-approval
Commands with multiline quoted strings (like beads descriptions) were not being auto-approved because parseCommand split by newlines before processing quoted strings, breaking the command into invalid fragments. This fix: - Adds replaceMultilineQuotes() to detect and replace multiline quoted strings with placeholders before splitting by newlines - Adds restoreMultilineQuotes() to restore the original quoted strings after parsing - Includes comprehensive tests for multiline quoted string handling Fixes #10226
This commit is contained in:
parent
78dc34498b
commit
3835a313ce
2 changed files with 222 additions and 3 deletions
151
src/shared/__tests__/parse-command.spec.ts
Normal file
151
src/shared/__tests__/parse-command.spec.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { parseCommand } from "../parse-command"
|
||||
|
||||
describe("parseCommand", () => {
|
||||
describe("basic command parsing", () => {
|
||||
it("should return empty array for empty command", () => {
|
||||
expect(parseCommand("")).toEqual([])
|
||||
expect(parseCommand(" ")).toEqual([])
|
||||
expect(parseCommand(null as any)).toEqual([])
|
||||
expect(parseCommand(undefined as any)).toEqual([])
|
||||
})
|
||||
|
||||
it("should parse simple commands", () => {
|
||||
expect(parseCommand("echo hello")).toEqual(["echo hello"])
|
||||
expect(parseCommand("git status")).toEqual(["git status"])
|
||||
})
|
||||
|
||||
it("should split commands by chain operators", () => {
|
||||
expect(parseCommand("echo hello && echo world")).toEqual(["echo hello", "echo world"])
|
||||
expect(parseCommand("echo hello || echo world")).toEqual(["echo hello", "echo world"])
|
||||
expect(parseCommand("echo hello; echo world")).toEqual(["echo hello", "echo world"])
|
||||
expect(parseCommand("echo hello | grep h")).toEqual(["echo hello", "grep h"])
|
||||
})
|
||||
|
||||
it("should split commands by newlines", () => {
|
||||
expect(parseCommand("echo hello\necho world")).toEqual(["echo hello", "echo world"])
|
||||
expect(parseCommand("echo hello\r\necho world")).toEqual(["echo hello", "echo world"])
|
||||
expect(parseCommand("echo hello\recho world")).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
|
||||
it("should skip empty lines", () => {
|
||||
expect(parseCommand("echo hello\n\necho world")).toEqual(["echo hello", "echo world"])
|
||||
expect(parseCommand("echo hello\n \necho world")).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("quoted string handling", () => {
|
||||
it("should preserve double-quoted strings", () => {
|
||||
expect(parseCommand('echo "hello world"')).toEqual(['echo "hello world"'])
|
||||
})
|
||||
|
||||
it("should preserve double-quoted strings with special characters", () => {
|
||||
expect(parseCommand('echo "hello && world"')).toEqual(['echo "hello && world"'])
|
||||
})
|
||||
|
||||
// Note: shell-quote strips single quotes but preserves their content
|
||||
it("should handle single-quoted strings (quotes stripped by shell-quote)", () => {
|
||||
expect(parseCommand("echo 'hello world'")).toEqual(["echo hello world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiline quoted string handling", () => {
|
||||
it("should preserve multiline double-quoted strings", () => {
|
||||
const command = 'bd create "This is a\nmultiline description"'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['bd create "This is a\nmultiline description"'])
|
||||
})
|
||||
|
||||
it("should preserve multiline single-quoted strings", () => {
|
||||
const command = "bd create 'This is a\nmultiline description'"
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(["bd create 'This is a\nmultiline description'"])
|
||||
})
|
||||
|
||||
it("should handle Windows-style line endings in multiline quotes", () => {
|
||||
const command = 'bd create "This is a\r\nmultiline description"'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['bd create "This is a\r\nmultiline description"'])
|
||||
})
|
||||
|
||||
it("should handle old Mac-style line endings in multiline quotes", () => {
|
||||
const command = 'bd create "This is a\rmultiline description"'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['bd create "This is a\rmultiline description"'])
|
||||
})
|
||||
|
||||
it("should preserve multiline strings with multiple newlines", () => {
|
||||
const command = 'echo "line1\nline2\nline3"'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['echo "line1\nline2\nline3"'])
|
||||
})
|
||||
|
||||
it("should handle multiple multiline quoted strings in one command", () => {
|
||||
const command = 'echo "first\nmultiline" && echo "second\nmultiline"'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['echo "first\nmultiline"', 'echo "second\nmultiline"'])
|
||||
})
|
||||
|
||||
it("should handle mixed single and double multiline quotes", () => {
|
||||
const command = "echo \"double\nquote\" && echo 'single\nquote'"
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['echo "double\nquote"', "echo 'single\nquote'"])
|
||||
})
|
||||
|
||||
it("should handle regular newlines between commands with multiline strings", () => {
|
||||
const command = 'bd create "This is a\nmultiline description"\necho done'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['bd create "This is a\nmultiline description"', "echo done"])
|
||||
})
|
||||
|
||||
it("should handle escaped quotes within multiline double-quoted strings", () => {
|
||||
const command = 'echo "line1\nline2 with \\"escaped\\" quotes"'
|
||||
const result = parseCommand(command)
|
||||
expect(result).toEqual(['echo "line1\nline2 with \\"escaped\\" quotes"'])
|
||||
})
|
||||
|
||||
it("should handle beads create command with multiline description", () => {
|
||||
// This is the exact use case from the bug report
|
||||
const command = `bd create "A tool that helps users manage their tasks.
|
||||
It supports multiple features:
|
||||
- Adding tasks
|
||||
- Removing tasks
|
||||
- Listing tasks"`
|
||||
const result = parseCommand(command)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toContain("bd create")
|
||||
expect(result[0]).toContain("A tool that helps users manage their tasks.")
|
||||
expect(result[0]).toContain("- Adding tasks")
|
||||
})
|
||||
})
|
||||
|
||||
describe("subshell handling", () => {
|
||||
// Note: The parser extracts subshell commands as separate entries
|
||||
it("should extract subshell commands", () => {
|
||||
expect(parseCommand("echo $(date)")).toEqual(["echo", "date"])
|
||||
})
|
||||
|
||||
it("should extract backtick commands", () => {
|
||||
expect(parseCommand("echo `date`")).toEqual(["echo", "date"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("variable handling", () => {
|
||||
it("should preserve variable references", () => {
|
||||
expect(parseCommand("echo $HOME")).toEqual(["echo $HOME"])
|
||||
})
|
||||
|
||||
it("should preserve parameter expansions", () => {
|
||||
expect(parseCommand("echo ${HOME}")).toEqual(["echo ${HOME}"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirection handling", () => {
|
||||
it("should preserve redirections", () => {
|
||||
expect(parseCommand("echo hello > output.txt")).toEqual(["echo hello > output.txt"])
|
||||
})
|
||||
|
||||
it("should preserve PowerShell-style redirections", () => {
|
||||
expect(parseCommand("command 2>&1")).toEqual(["command 2>&1"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,63 @@ import { parse } from "shell-quote"
|
|||
|
||||
export type ShellToken = string | { op: string } | { command: string }
|
||||
|
||||
/**
|
||||
* Replace multiline quoted strings with placeholders before splitting by newlines.
|
||||
* This prevents quoted strings that span multiple lines from being incorrectly split.
|
||||
*
|
||||
* @param command - The command string to process
|
||||
* @returns An object with the processed command and arrays of replaced quotes
|
||||
*/
|
||||
function replaceMultilineQuotes(command: string): {
|
||||
processedCommand: string
|
||||
multilineDoubleQuotes: string[]
|
||||
multilineSingleQuotes: string[]
|
||||
} {
|
||||
const multilineDoubleQuotes: string[] = []
|
||||
const multilineSingleQuotes: string[] = []
|
||||
|
||||
// Replace multiline double-quoted strings with placeholders
|
||||
// This regex matches double-quoted strings that contain newlines
|
||||
let processedCommand = command.replace(/"([^"\\]|\\.)*"/gs, (match) => {
|
||||
if (/\r\n|\r|\n/.test(match)) {
|
||||
multilineDoubleQuotes.push(match)
|
||||
return `__MLQUOTE_D_${multilineDoubleQuotes.length - 1}__`
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// Replace multiline single-quoted strings with placeholders
|
||||
// Single quotes don't support escape sequences in shell, but we still need to handle them
|
||||
processedCommand = processedCommand.replace(/'[^']*'/gs, (match) => {
|
||||
if (/\r\n|\r|\n/.test(match)) {
|
||||
multilineSingleQuotes.push(match)
|
||||
return `__MLQUOTE_S_${multilineSingleQuotes.length - 1}__`
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
return { processedCommand, multilineDoubleQuotes, multilineSingleQuotes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore multiline quoted string placeholders back to their original values.
|
||||
*
|
||||
* @param command - The command with placeholders
|
||||
* @param multilineDoubleQuotes - Array of replaced double-quoted strings
|
||||
* @param multilineSingleQuotes - Array of replaced single-quoted strings
|
||||
* @returns The command with placeholders restored
|
||||
*/
|
||||
function restoreMultilineQuotes(
|
||||
command: string,
|
||||
multilineDoubleQuotes: string[],
|
||||
multilineSingleQuotes: string[],
|
||||
): string {
|
||||
let result = command
|
||||
result = result.replace(/__MLQUOTE_D_(\d+)__/g, (_, i) => multilineDoubleQuotes[parseInt(i)])
|
||||
result = result.replace(/__MLQUOTE_S_(\d+)__/g, (_, i) => multilineSingleQuotes[parseInt(i)])
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a command string into individual sub-commands by
|
||||
* chaining operators (&&, ||, ;, |, or &) and newlines.
|
||||
|
|
@ -12,15 +69,20 @@ export type ShellToken = string | { op: string } | { command: string }
|
|||
* - PowerShell redirections (2>&1)
|
||||
* - Chain operators (&&, ||, ;, |, &)
|
||||
* - Newlines as command separators
|
||||
* - Multiline quoted strings (preserves them as single commands)
|
||||
*/
|
||||
export function parseCommand(command: string): string[] {
|
||||
if (!command?.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Split by newlines first (handle different line ending formats)
|
||||
// First, replace multiline quoted strings with placeholders
|
||||
// This prevents them from being incorrectly split when we split by newlines
|
||||
const { processedCommand, multilineDoubleQuotes, multilineSingleQuotes } = replaceMultilineQuotes(command)
|
||||
|
||||
// Split by newlines (handle different line ending formats)
|
||||
// This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
|
||||
const lines = command.split(/\r\n|\r|\n/)
|
||||
const lines = processedCommand.split(/\r\n|\r|\n/)
|
||||
const allCommands: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
@ -31,7 +93,13 @@ export function parseCommand(command: string): string[] {
|
|||
|
||||
// Process each line through the existing parsing logic
|
||||
const lineCommands = parseCommandLine(line)
|
||||
allCommands.push(...lineCommands)
|
||||
|
||||
// Restore multiline quotes in each parsed command
|
||||
const restoredCommands = lineCommands.map((cmd) =>
|
||||
restoreMultilineQuotes(cmd, multilineDoubleQuotes, multilineSingleQuotes),
|
||||
)
|
||||
|
||||
allCommands.push(...restoredCommands)
|
||||
}
|
||||
|
||||
return allCommands
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue