mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
fix: handle multiline quoted strings in command auto-approval with quote-aware parsing
This commit is contained in:
parent
78dc34498b
commit
35c8d13a2b
2 changed files with 306 additions and 4 deletions
217
src/shared/__tests__/parse-command.spec.ts
Normal file
217
src/shared/__tests__/parse-command.spec.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { parseCommand, joinQuotedLines } from "../parse-command"
|
||||
|
||||
describe("joinQuotedLines", () => {
|
||||
it("should return empty array for empty input", () => {
|
||||
expect(joinQuotedLines("")).toEqual([])
|
||||
expect(joinQuotedLines(null as any)).toEqual([])
|
||||
expect(joinQuotedLines(undefined as any)).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle single line commands without quotes", () => {
|
||||
expect(joinQuotedLines("echo hello")).toEqual(["echo hello"])
|
||||
expect(joinQuotedLines("git status")).toEqual(["git status"])
|
||||
})
|
||||
|
||||
it("should split multiple lines without quotes", () => {
|
||||
expect(joinQuotedLines("echo hello\necho world")).toEqual(["echo hello", "echo world"])
|
||||
expect(joinQuotedLines("git status\ngit log")).toEqual(["git status", "git log"])
|
||||
})
|
||||
|
||||
it("should keep multiline double-quoted strings together", () => {
|
||||
const input = 'bd create "This is a\nmultiline description"'
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should keep multiline single-quoted strings together", () => {
|
||||
const input = "bd create 'This is a\nmultiline description'"
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should handle Windows line endings (CRLF) within quotes", () => {
|
||||
const input = 'bd create "This is a\r\nmultiline description"'
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should handle Windows line endings (CRLF) between commands", () => {
|
||||
const input = "echo hello\r\necho world"
|
||||
expect(joinQuotedLines(input)).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
|
||||
it("should handle old Mac line endings (CR) within quotes", () => {
|
||||
const input = 'bd create "This is a\rmultiline description"'
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should handle multiple newlines within quotes", () => {
|
||||
const input = 'bd create "Line 1\nLine 2\nLine 3"'
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should handle quoted strings followed by newline and another command", () => {
|
||||
const input = 'bd create "multiline\ndesc"\necho done'
|
||||
expect(joinQuotedLines(input)).toEqual(['bd create "multiline\ndesc"', "echo done"])
|
||||
})
|
||||
|
||||
it("should handle escaped quotes within double-quoted strings", () => {
|
||||
const input = 'echo "hello \\"world\\"\nline 2"'
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should handle single quotes within double quotes", () => {
|
||||
const input = 'echo "it\'s\na test"'
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should handle double quotes within single quotes", () => {
|
||||
// Single quotes preserve everything literally
|
||||
const input = "echo 'He said \"hello\"\nworld'"
|
||||
expect(joinQuotedLines(input)).toEqual([input])
|
||||
})
|
||||
|
||||
it("should skip empty lines between commands", () => {
|
||||
expect(joinQuotedLines("echo hello\n\necho world")).toEqual(["echo hello", "echo world"])
|
||||
expect(joinQuotedLines("echo hello\n \necho world")).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
|
||||
it("should handle complex multiline commands with command chaining", () => {
|
||||
const input = 'bd create "desc\nmore" && npm install\necho done'
|
||||
expect(joinQuotedLines(input)).toEqual(['bd create "desc\nmore" && npm install', "echo done"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseCommand", () => {
|
||||
it("should return empty array for empty input", () => {
|
||||
expect(parseCommand("")).toEqual([])
|
||||
expect(parseCommand(" ")).toEqual([])
|
||||
expect(parseCommand(null as any)).toEqual([])
|
||||
expect(parseCommand(undefined as any)).toEqual([])
|
||||
})
|
||||
|
||||
it("should parse simple single command", () => {
|
||||
expect(parseCommand("echo hello")).toEqual(["echo hello"])
|
||||
expect(parseCommand("git status")).toEqual(["git status"])
|
||||
})
|
||||
|
||||
it("should parse command chain with &&", () => {
|
||||
expect(parseCommand("npm install && npm test")).toEqual(["npm install", "npm test"])
|
||||
})
|
||||
|
||||
it("should parse command chain with ||", () => {
|
||||
expect(parseCommand("npm test || echo failed")).toEqual(["npm test", "echo failed"])
|
||||
})
|
||||
|
||||
it("should parse command chain with ;", () => {
|
||||
expect(parseCommand("echo hello; echo world")).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
|
||||
it("should parse commands on separate lines", () => {
|
||||
expect(parseCommand("echo hello\necho world")).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
|
||||
it("should handle multiline double-quoted strings as single command", () => {
|
||||
const input = 'bd create "This is a\nmultiline description"'
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(['bd create "This is a\nmultiline description"'])
|
||||
})
|
||||
|
||||
it("should handle multiline single-quoted strings as single command", () => {
|
||||
const input = "bd create 'This is a\nmultiline description'"
|
||||
const result = parseCommand(input)
|
||||
// Note: shell-quote strips single quotes, but the multiline content stays together
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]).toContain("bd create")
|
||||
expect(result[0]).toContain("This is a")
|
||||
expect(result[0]).toContain("multiline description")
|
||||
})
|
||||
|
||||
it("should handle multiline quoted string with command chain on same line", () => {
|
||||
const input = 'bd create "desc\nmore" && echo done'
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(['bd create "desc\nmore"', "echo done"])
|
||||
})
|
||||
|
||||
it("should handle multiline quoted string followed by newline command", () => {
|
||||
const input = 'bd create "desc\nmore"\necho done'
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(['bd create "desc\nmore"', "echo done"])
|
||||
})
|
||||
|
||||
it("should handle real-world beads create command", () => {
|
||||
const input = `bd create "This is the first line.
|
||||
This is the second line.
|
||||
This is the third line."`
|
||||
const result = parseCommand(input)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]).toContain("bd create")
|
||||
expect(result[0]).toContain("first line")
|
||||
expect(result[0]).toContain("third line")
|
||||
})
|
||||
|
||||
it("should handle beads command followed by another command", () => {
|
||||
const input = `bd create "Multiline
|
||||
description" && npm install`
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(['bd create "Multiline\ndescription"', "npm install"])
|
||||
})
|
||||
|
||||
it("should handle multiple multiline quoted strings in sequence", () => {
|
||||
const input = 'echo "Line 1\nLine 2" && echo "Line 3\nLine 4"'
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(['echo "Line 1\nLine 2"', 'echo "Line 3\nLine 4"'])
|
||||
})
|
||||
|
||||
it("should handle Windows line endings in multiline quotes", () => {
|
||||
const input = 'bd create "Line 1\r\nLine 2"'
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(['bd create "Line 1\r\nLine 2"'])
|
||||
})
|
||||
|
||||
it("should preserve variable references", () => {
|
||||
const input = 'echo $HOME && echo "path: $PATH"'
|
||||
const result = parseCommand(input)
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]).toContain("$HOME")
|
||||
expect(result[1]).toContain("$PATH")
|
||||
})
|
||||
|
||||
it("should handle empty lines in the input", () => {
|
||||
const input = "echo hello\n\necho world"
|
||||
const result = parseCommand(input)
|
||||
expect(result).toEqual(["echo hello", "echo world"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseCommand - auto-approval validation scenarios", () => {
|
||||
// These tests verify the fix for issue #10226:
|
||||
// Multiline commands with quoted strings should be correctly parsed
|
||||
// for auto-approval validation
|
||||
|
||||
it("should correctly identify bd command prefix with multiline description", () => {
|
||||
const input = 'bd create "This is a\nmultiline\ndescription"'
|
||||
const result = parseCommand(input)
|
||||
|
||||
// Should be a single command that starts with "bd"
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].startsWith("bd ")).toBe(true)
|
||||
})
|
||||
|
||||
it("should correctly split bd command from other commands", () => {
|
||||
const input = 'bd create "desc\nmore" && npm install'
|
||||
const result = parseCommand(input)
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].startsWith("bd ")).toBe(true)
|
||||
expect(result[1].startsWith("npm ")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle multiline command on separate lines from other commands", () => {
|
||||
const input = `bd create "multiline
|
||||
description"
|
||||
echo done`
|
||||
const result = parseCommand(input)
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0].startsWith("bd ")).toBe(true)
|
||||
expect(result[1]).toBe("echo done")
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,91 @@ import { parse } from "shell-quote"
|
|||
|
||||
export type ShellToken = string | { op: string } | { command: string }
|
||||
|
||||
/**
|
||||
* Join lines that are within quoted strings.
|
||||
*
|
||||
* This function scans through the input character by character, tracking
|
||||
* whether we're inside a quoted string. When we encounter a newline while
|
||||
* inside quotes, we preserve it rather than splitting the command.
|
||||
*
|
||||
* This approach:
|
||||
* - Preserves the original content exactly (no placeholder replacement/restoration)
|
||||
* - Properly handles both single and double quotes
|
||||
* - Handles escaped quotes within strings
|
||||
* - Handles different line ending formats (CRLF, LF, CR)
|
||||
*
|
||||
* @param command - The command string that may contain multiline quoted strings
|
||||
* @returns Array of logical command lines with multiline quotes joined
|
||||
*/
|
||||
export function joinQuotedLines(command: string): string[] {
|
||||
if (!command) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: string[] = []
|
||||
let currentLine = ""
|
||||
let inDoubleQuote = false
|
||||
let inSingleQuote = false
|
||||
let i = 0
|
||||
|
||||
while (i < command.length) {
|
||||
const char = command[i]
|
||||
const prevChar = i > 0 ? command[i - 1] : ""
|
||||
|
||||
// Handle escape sequences (only in double quotes, single quotes are literal)
|
||||
const isEscaped = prevChar === "\\" && inDoubleQuote
|
||||
|
||||
// Handle quote state changes
|
||||
if (char === '"' && !inSingleQuote && !isEscaped) {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
currentLine += char
|
||||
} else if (char === "'" && !inDoubleQuote) {
|
||||
// Single quotes can't be escaped, so we just toggle
|
||||
inSingleQuote = !inSingleQuote
|
||||
currentLine += char
|
||||
} else if (char === "\r" || char === "\n") {
|
||||
// Handle different line endings: \r\n, \n, or \r
|
||||
if (char === "\r" && command[i + 1] === "\n") {
|
||||
// CRLF - consume both characters
|
||||
if (inDoubleQuote || inSingleQuote) {
|
||||
// Inside quotes - preserve the newline in the command
|
||||
currentLine += "\r\n"
|
||||
} else {
|
||||
// Outside quotes - this is a line separator
|
||||
if (currentLine.trim()) {
|
||||
result.push(currentLine)
|
||||
}
|
||||
currentLine = ""
|
||||
}
|
||||
i++ // Skip the \n since we consumed it
|
||||
} else {
|
||||
// LF or CR alone
|
||||
if (inDoubleQuote || inSingleQuote) {
|
||||
// Inside quotes - preserve the newline in the command
|
||||
currentLine += char
|
||||
} else {
|
||||
// Outside quotes - this is a line separator
|
||||
if (currentLine.trim()) {
|
||||
result.push(currentLine)
|
||||
}
|
||||
currentLine = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentLine += char
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
// Don't forget the last line
|
||||
if (currentLine.trim()) {
|
||||
result.push(currentLine)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a command string into individual sub-commands by
|
||||
* chaining operators (&&, ||, ;, |, or &) and newlines.
|
||||
|
|
@ -11,16 +96,16 @@ export type ShellToken = string | { op: string } | { command: string }
|
|||
* - Subshell commands ($(cmd), `cmd`, <(cmd), >(cmd))
|
||||
* - PowerShell redirections (2>&1)
|
||||
* - Chain operators (&&, ||, ;, |, &)
|
||||
* - Newlines as command separators
|
||||
* - Newlines as command separators (respecting quoted strings)
|
||||
*/
|
||||
export function parseCommand(command: string): string[] {
|
||||
if (!command?.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Split by newlines first (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/)
|
||||
// Join lines that are within quoted strings first
|
||||
// This preserves multiline quoted content as single logical lines
|
||||
const lines = joinQuotedLines(command)
|
||||
const allCommands: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue