Skip to content

Commit d52a172

Browse files
fix(shell): handle logical OR operators (||) in pipe detection (#777)
Fixes #772 The _find_first_unquoted_pipe() function was finding the first | character without checking if it's part of a || (logical OR) operator. This caused commands like 'cat file1 || cat file2 | head' to be incorrectly split, creating syntax errors. Changes: - Check if found pipe is part of || operator - Skip both characters if so and continue searching - Add comprehensive test cases for || with pipes, &&, and combinations Test cases cover: - || followed by | in same command - Simple || operators - || with stderr redirection - Multiple || in sequence - Combination of || and && - Full pattern from issue: stderr redirect + || + pipe
1 parent 9c7b487 commit d52a172

File tree

2 files changed

+175
-0
lines changed

2 files changed

+175
-0
lines changed

gptme/tools/shell.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ def _find_first_unquoted_pipe(command: str) -> int | None:
562562
"""Find the position of the first pipe operator that's not in quotes.
563563
564564
Returns None if no unquoted pipe is found.
565+
Skips logical OR operators (||).
565566
"""
566567
quoted_regions = _find_quotes(command)
567568

@@ -573,6 +574,12 @@ def _find_first_unquoted_pipe(command: str) -> int | None:
573574

574575
# Check if this pipe is inside quotes
575576
if not _is_in_quoted_region(pipe_pos, quoted_regions):
577+
# Check if this is part of || (logical OR)
578+
if pipe_pos + 1 < len(command) and command[pipe_pos + 1] == "|":
579+
# Skip the || operator
580+
pos = pipe_pos + 2
581+
continue
582+
576583
return pipe_pos
577584

578585
# Try next pipe

tests/test_shell_issue772.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Tests for shell tool issues reported in #772.
2+
3+
These test cases verify that the shell tool correctly handles:
4+
1. Logical OR operators (||)
5+
2. Combinations of || with pipe operators (|)
6+
3. Complex command chaining with mixed operators
7+
"""
8+
9+
import tempfile
10+
from collections.abc import Generator
11+
12+
import pytest
13+
14+
from gptme.tools.shell import ShellSession
15+
16+
17+
@pytest.fixture
18+
def shell() -> Generator[ShellSession, None, None]:
19+
shell = ShellSession()
20+
yield shell
21+
shell.close()
22+
23+
24+
def test_logical_or_with_pipe(shell):
25+
"""Test that logical OR (||) followed by pipe (|) works correctly.
26+
27+
Issue: cat .env.local 2>/dev/null || cat .env.example | head -20
28+
Error: bash: line 33: syntax error near unexpected token `|'
29+
bash: line 33: `cat .env.local 2>/dev/null < /dev/null | | cat ...`
30+
31+
The shell tool was treating the first | in || as a pipe operator,
32+
causing it to split the command incorrectly and create double pipes.
33+
"""
34+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
35+
f.write("line1\nline2\nline3\nline4\nline5\n")
36+
temp_file = f.name
37+
38+
try:
39+
# Test || followed by | in the same command
40+
code = f"cat /nonexistent 2>/dev/null || cat {temp_file} | head -3"
41+
42+
returncode, stdout, stderr = shell.run(code)
43+
44+
# Should not have syntax errors about pipes
45+
assert "syntax error near unexpected token" not in stderr.lower()
46+
assert "| |" not in stderr # No double pipes
47+
48+
# Should output first 3 lines from the fallback file
49+
lines = stdout.strip().split("\n")
50+
assert len(lines) == 3
51+
assert "line1" in stdout
52+
assert returncode == 0
53+
finally:
54+
import os
55+
56+
os.unlink(temp_file)
57+
58+
59+
def test_logical_or_simple(shell):
60+
"""Test simple logical OR operator without pipes.
61+
62+
Verify that || operator works correctly in basic cases.
63+
"""
64+
code = "false || echo 'fallback'"
65+
66+
returncode, stdout, stderr = shell.run(code)
67+
68+
# Should execute fallback command
69+
assert "fallback" in stdout
70+
assert returncode == 0
71+
72+
73+
def test_logical_or_with_file_redirect(shell):
74+
"""Test logical OR with stderr redirection.
75+
76+
This is the core case from Issue #772: checking if a file exists
77+
with stderr redirected, with a fallback command.
78+
"""
79+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
80+
f.write("fallback content\n")
81+
temp_file = f.name
82+
83+
try:
84+
# Try to read non-existent file, fallback to existing file
85+
code = f"cat /nonexistent 2>/dev/null || cat {temp_file}"
86+
87+
returncode, stdout, stderr = shell.run(code)
88+
89+
# Should execute fallback and read temp file
90+
assert "fallback content" in stdout
91+
assert returncode == 0
92+
93+
# Should not have stderr about /nonexistent (redirected)
94+
assert "nonexistent" not in stderr.lower()
95+
finally:
96+
import os
97+
98+
os.unlink(temp_file)
99+
100+
101+
def test_multiple_logical_or(shell):
102+
"""Test multiple || operators in sequence.
103+
104+
Verify that chains of || operators are handled correctly.
105+
"""
106+
code = "false || false || echo 'third try'"
107+
108+
returncode, stdout, stderr = shell.run(code)
109+
110+
assert "third try" in stdout
111+
assert returncode == 0
112+
113+
114+
def test_logical_or_and_logical_and(shell):
115+
"""Test combination of || and && operators.
116+
117+
Verify that both logical operators can coexist in the same command.
118+
"""
119+
code = "true && echo 'success' || echo 'failure'"
120+
121+
returncode, stdout, stderr = shell.run(code)
122+
123+
# Should execute first echo (true && echo)
124+
assert "success" in stdout
125+
# Should not execute fallback
126+
assert "failure" not in stdout
127+
assert returncode == 0
128+
129+
130+
def test_pipe_after_logical_or_with_stderr(shell):
131+
"""Test the exact problematic pattern from Issue #772.
132+
133+
cat file1 2>/dev/null || cat file2 | head -20
134+
135+
This combines:
136+
- stderr redirect (2>/dev/null)
137+
- logical OR (||)
138+
- pipe operator (|)
139+
"""
140+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
141+
# Create file with many lines
142+
for i in range(30):
143+
f.write(f"line{i}\n")
144+
temp_file = f.name
145+
146+
try:
147+
code = f"cat /nonexistent 2>/dev/null || cat {temp_file} | head -5"
148+
149+
returncode, stdout, stderr = shell.run(code)
150+
151+
# Should not have syntax errors
152+
assert "syntax error" not in stderr.lower()
153+
assert "unexpected token" not in stderr.lower()
154+
155+
# Should output first 5 lines
156+
lines = stdout.strip().split("\n")
157+
assert len(lines) == 5
158+
assert "line0" in stdout
159+
assert "line4" in stdout
160+
# Should NOT have line5 or beyond (limited by head -5)
161+
assert "line5" not in stdout
162+
163+
# Return code should be 0 or 141 (SIGPIPE from head)
164+
assert returncode in (0, 141)
165+
finally:
166+
import os
167+
168+
os.unlink(temp_file)

0 commit comments

Comments
 (0)