Skip to content

Commit b0820ac

Browse files
authored
fix: resolve 'Bad substitution' error in command parsing (RooCodeInc#5743)
1 parent 1c26bbc commit b0820ac

File tree

2 files changed

+194
-5
lines changed

2 files changed

+194
-5
lines changed

webview-ui/src/utils/__tests__/command-validation.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,123 @@ done`
276276
parseCommand(problematicPart)
277277
}).not.toThrow("Bad substitution")
278278
})
279+
280+
it("should handle bash arithmetic expressions with $(())", () => {
281+
// Test the exact script from the user's error
282+
const bashScript = `jsx_files=$(find resources/js -name "*.jsx" -type f -not -path "*/node_modules/*")
283+
count=0
284+
for file in $jsx_files; do
285+
ts_file="\${file%.jsx}.tsx"
286+
if [ ! -f "$ts_file" ]; then
287+
cp "$file" "$ts_file"
288+
count=$((count + 1))
289+
fi
290+
done
291+
echo "Successfully converted $count .jsx files to .tsx"`
292+
293+
expect(() => {
294+
parseCommand(bashScript)
295+
}).not.toThrow("Bad substitution: calc.add")
296+
})
297+
298+
it("should correctly parse commands with arithmetic expressions", () => {
299+
const result = parseCommand("count=$((count + 1)) && echo $count")
300+
expect(result).toEqual(["count=$((count + 1))", "echo $count"])
301+
})
302+
303+
it("should handle nested arithmetic expressions", () => {
304+
const result = parseCommand("result=$((10 * (5 + 3))) && echo $result")
305+
expect(result).toEqual(["result=$((10 * (5 + 3)))", "echo $result"])
306+
})
307+
308+
it("should handle arithmetic expressions with variables", () => {
309+
const result = parseCommand("total=$((price * quantity + tax))")
310+
expect(result).toEqual(["total=$((price * quantity + tax))"])
311+
})
312+
313+
it("should handle complex parameter expansions without errors", () => {
314+
const commands = [
315+
"echo ${var:-default}",
316+
"echo ${#array[@]}",
317+
"echo ${var%suffix}",
318+
"echo ${var#prefix}",
319+
"echo ${var/pattern/replacement}",
320+
"echo ${!var}",
321+
"echo ${var:0:5}",
322+
"echo ${var,,}",
323+
"echo ${var^^}",
324+
]
325+
326+
commands.forEach((cmd) => {
327+
expect(() => {
328+
parseCommand(cmd)
329+
}).not.toThrow()
330+
})
331+
})
332+
333+
it("should handle process substitutions without errors", () => {
334+
const commands = [
335+
"diff <(sort file1) <(sort file2)",
336+
"command >(gzip > output.gz)",
337+
"while read line; do echo $line; done < <(cat file)",
338+
]
339+
340+
commands.forEach((cmd) => {
341+
expect(() => {
342+
parseCommand(cmd)
343+
}).not.toThrow()
344+
})
345+
})
346+
347+
it("should handle special bash variables without errors", () => {
348+
const commands = [
349+
"echo $?",
350+
"echo $!",
351+
"echo $#",
352+
"echo $$",
353+
"echo $@",
354+
"echo $*",
355+
"echo $-",
356+
"echo $0",
357+
"echo $1 $2 $3",
358+
]
359+
360+
commands.forEach((cmd) => {
361+
expect(() => {
362+
parseCommand(cmd)
363+
}).not.toThrow()
364+
})
365+
})
366+
367+
it("should handle mixed complex bash constructs", () => {
368+
const complexCommand = `
369+
for file in \${files[@]}; do
370+
if [[ -f "\${file%.txt}.bak" ]]; then
371+
count=\$((count + 1))
372+
echo "Processing \${file} (\$count/\${#files[@]})"
373+
result=\$(process_file "\$file" 2>&1)
374+
if [[ \$? -eq 0 ]]; then
375+
echo "Success: \$result" >(logger)
376+
fi
377+
fi
378+
done
379+
`
380+
381+
expect(() => {
382+
parseCommand(complexCommand)
383+
}).not.toThrow()
384+
})
385+
386+
it("should handle fallback parsing when shell-quote fails", () => {
387+
// Test a command that might cause shell-quote to fail
388+
const problematicCommand = "echo ${unclosed"
389+
390+
expect(() => {
391+
const result = parseCommand(problematicCommand)
392+
// Should not throw and should return some result
393+
expect(Array.isArray(result)).toBe(true)
394+
}).not.toThrow()
395+
})
279396
})
280397

281398
describe("isAutoApprovedCommand (legacy behavior)", () => {

webview-ui/src/utils/command-validation.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,48 @@ export function parseCommand(command: string): string[] {
7676
const subshells: string[] = []
7777
const quotes: string[] = []
7878
const arrayIndexing: string[] = []
79+
const arithmeticExpressions: string[] = []
80+
const variables: string[] = []
81+
const parameterExpansions: string[] = []
82+
const processSubstitutions: string[] = []
7983

8084
// First handle PowerShell redirections by temporarily replacing them
8185
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
8286
redirections.push(match)
8387
return `__REDIR_${redirections.length - 1}__`
8488
})
8589

86-
// Handle array indexing expressions: ${array[...]} pattern and partial expressions
87-
processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => {
88-
arrayIndexing.push(match)
89-
return `__ARRAY_${arrayIndexing.length - 1}__`
90+
// Handle arithmetic expressions: $((...)) pattern
91+
// Match the entire arithmetic expression including nested parentheses
92+
processedCommand = processedCommand.replace(/\$\(\([^)]*(?:\)[^)]*)*\)\)/g, (match) => {
93+
arithmeticExpressions.push(match)
94+
return `__ARITH_${arithmeticExpressions.length - 1}__`
95+
})
96+
97+
// Handle parameter expansions: ${...} patterns (including array indexing)
98+
// This covers ${var}, ${var:-default}, ${var:+alt}, ${#var}, ${var%pattern}, etc.
99+
processedCommand = processedCommand.replace(/\$\{[^}]+\}/g, (match) => {
100+
parameterExpansions.push(match)
101+
return `__PARAM_${parameterExpansions.length - 1}__`
102+
})
103+
104+
// Handle process substitutions: <(...) and >(...)
105+
processedCommand = processedCommand.replace(/[<>]\([^)]+\)/g, (match) => {
106+
processSubstitutions.push(match)
107+
return `__PROCSUB_${processSubstitutions.length - 1}__`
108+
})
109+
110+
// Handle simple variable references: $varname pattern
111+
// This prevents shell-quote from splitting $count into separate tokens
112+
processedCommand = processedCommand.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
113+
variables.push(match)
114+
return `__VAR_${variables.length - 1}__`
115+
})
116+
117+
// Handle special bash variables: $?, $!, $#, $$, $@, $*, $-, $0-$9
118+
processedCommand = processedCommand.replace(/\$[?!#$@*\-0-9]/g, (match) => {
119+
variables.push(match)
120+
return `__VAR_${variables.length - 1}__`
90121
})
91122

92123
// Then handle subshell commands
@@ -106,7 +137,40 @@ export function parseCommand(command: string): string[] {
106137
return `__QUOTE_${quotes.length - 1}__`
107138
})
108139

109-
const tokens = parse(processedCommand) as ShellToken[]
140+
let tokens: ShellToken[]
141+
try {
142+
tokens = parse(processedCommand) as ShellToken[]
143+
} catch (error: any) {
144+
// If shell-quote fails to parse, fall back to simple splitting
145+
console.warn("shell-quote parse error:", error.message, "for command:", processedCommand)
146+
147+
// Simple fallback: split by common operators
148+
const fallbackCommands = processedCommand
149+
.split(/(?:&&|\|\||;|\|)/)
150+
.map((cmd) => cmd.trim())
151+
.filter((cmd) => cmd.length > 0)
152+
153+
// Restore all placeholders for each command
154+
return fallbackCommands.map((cmd) => {
155+
let result = cmd
156+
// Restore quotes
157+
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
158+
// Restore redirections
159+
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
160+
// Restore array indexing expressions
161+
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
162+
// Restore arithmetic expressions
163+
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
164+
// Restore parameter expansions
165+
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
166+
// Restore process substitutions
167+
result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)])
168+
// Restore variable references
169+
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
170+
return result
171+
})
172+
}
173+
110174
const commands: string[] = []
111175
let currentCommand: string[] = []
112176

@@ -151,6 +215,14 @@ export function parseCommand(command: string): string[] {
151215
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
152216
// Restore array indexing expressions
153217
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
218+
// Restore arithmetic expressions
219+
result = result.replace(/__ARITH_(\d+)__/g, (_, i) => arithmeticExpressions[parseInt(i)])
220+
// Restore parameter expansions
221+
result = result.replace(/__PARAM_(\d+)__/g, (_, i) => parameterExpansions[parseInt(i)])
222+
// Restore process substitutions
223+
result = result.replace(/__PROCSUB_(\d+)__/g, (_, i) => processSubstitutions[parseInt(i)])
224+
// Restore variable references
225+
result = result.replace(/__VAR_(\d+)__/g, (_, i) => variables[parseInt(i)])
154226
return result
155227
})
156228
}

0 commit comments

Comments
 (0)