fix(bash): use newline separator for sentinel to fix heredoc hangs
Build Nanobot OAuth / build (push) Successful in 6m35s
Build Nanobot OAuth / cleanup (push) Successful in 1s

When the LLM sends heredoc commands (cat << 'EOF'), the semicolon
sentinel (EOF; echo '<<exit>>') prevents bash from recognizing the
terminator, causing the session to hang until the 120s timeout.
Confirmed in production logs: the exact command that caused the hang.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 10:14:13 +00:00
parent 1a85333e4c
commit 34ed4345fc
3 changed files with 72 additions and 3 deletions
+3 -2
View File
@@ -71,9 +71,10 @@ class _BashSession:
assert self._process.stdout
assert self._process.stderr
# Send command + sentinel
# Send command + sentinel on its own line so heredoc terminators
# aren't corrupted (EOF; echo '...' ≠ EOF)
self._process.stdin.write(
command.encode() + f"; echo '{self._sentinel}'\n".encode()
command.encode() + f"\necho '{self._sentinel}'\n".encode()
)
await self._process.stdin.drain()
+68
View File
@@ -0,0 +1,68 @@
"""Test that bash tool handles heredoc commands correctly.
Reproduces the bug where `; echo '<<exit>>'` appended on the same line
as a heredoc terminator prevents bash from recognizing the terminator,
causing the session to hang forever.
"""
import asyncio
import pytest
from nanobot.agent.tools.anthropic.bash import BashTool20250124
@pytest.mark.asyncio
async def test_heredoc_command():
"""Heredoc commands must complete without hanging."""
tool = BashTool20250124()
# Simple command works
result = await tool(command="echo hello")
assert result.output == "hello"
# Heredoc command — this is the exact pattern that caused the hang
result = await asyncio.wait_for(
tool(command="cat << 'EOF'\nline1\nline2\nEOF"),
timeout=5.0,
)
assert "line1" in result.output
assert "line2" in result.output
@pytest.mark.asyncio
async def test_heredoc_append_to_file():
"""Heredoc append (the exact pattern the LLM uses) must work."""
tool = BashTool20250124()
result = await asyncio.wait_for(
tool(command="cat >> /tmp/test_heredoc_bash.txt << 'EOF'\nhello world\nEOF"),
timeout=5.0,
)
# Should complete without error
assert result.error is None or result.error == ""
# Verify the file was written
result2 = await tool(command="cat /tmp/test_heredoc_bash.txt")
assert "hello world" in result2.output
# Cleanup
await tool(command="rm -f /tmp/test_heredoc_bash.txt")
@pytest.mark.asyncio
async def test_regular_commands_still_work():
"""Ensure regular commands still work after the fix."""
tool = BashTool20250124()
# Semicolons in commands
result = await tool(command="echo a; echo b")
assert "a" in result.output
assert "b" in result.output
# Multiline script
result = await tool(command="for i in 1 2 3; do echo $i; done")
assert "1" in result.output
assert "3" in result.output
# Command with exit code
result = await tool(command="true")
assert result.output == "(no output)" or result.output is not None
+1 -1
View File
@@ -40,7 +40,7 @@ async def test_bash_tool_restart():
# Restart
result = await tool(restart=True)
assert "restarted" in result.output.lower()
assert "restarted" in (result.system or result.output or "").lower()
# Variable should be gone
result2 = await tool(command="echo $TEST_VAR")