-
Notifications
You must be signed in to change notification settings - Fork 167
Description
Summary
Terminal query responses (such as DSR - Device Status Report) leak to stdin when using the SDK in interactive terminal environments. This causes escape code sequences to appear as garbage in the shell prompt or corrupt subsequent input() calls.
How the Issue Manifests
When CLI tools or the SDK's Rich-based visualizer send terminal queries (e.g., cursor position requests, background color queries), the terminal responds by writing escape sequences to stdin. If these responses are not consumed, they:
- Corrupt the next
input()call - The escape codes become part of the user's input - Leak to the shell prompt - After the SDK exits, garbage like
;1Ror^[[23;1Rappears in the terminal - Accumulate across conversation turns - Each agent step can add more leaked data
Example of leaked data appearing after script exit:
$ python my_agent.py
[agent runs successfully]
$ ;1R # ← This garbage appeared from leaked escape codes
rgb:30fb/3708/41af # ← Background color response leaked
Potential Impact on SDK Clients
- CLI applications using
input()- User prompts become corrupted with escape codes - Interactive agents - Multi-turn conversations accumulate leaked data
- Shell integration - Users see garbage in their terminal after running SDK-based tools
- Automation scripts - Leaked data can interfere with piped commands or shell scripts
Reproduction Instructions
Environment Compatibility
| Environment | DSR Query Leak | Notes |
|---|---|---|
| Linux (OpenHands runtime) | ✅ Reproducible | DSR queries leak reliably |
| macOS Terminal.app | ✅ Reproducible | More queries leak (including OSC 11) |
| macOS iTerm2 | Likely affected (not yet verified) | |
| CI/non-TTY | ❌ Not applicable | No TTY, no terminal queries |
Minimal Reproduction (No API Key Needed)
This surgical test directly injects terminal queries to demonstrate the leak mechanism:
#!/usr/bin/env python3
"""Minimal reproduction of ANSI escape code leak."""
import os
import select
import sys
import time
def check_stdin() -> bytes:
"""Check for pending data in stdin (non-blocking)."""
if not sys.stdin.isatty():
return b""
try:
import termios
old = termios.tcgetattr(sys.stdin)
new = list(old)
new[3] &= ~(termios.ICANON | termios.ECHO)
new[6][termios.VMIN] = 0
new[6][termios.VTIME] = 0
termios.tcsetattr(sys.stdin, termios.TCSANOW, new)
data = b""
if select.select([sys.stdin], [], [], 0)[0]:
data = os.read(sys.stdin.fileno(), 4096)
termios.tcsetattr(sys.stdin, termios.TCSANOW, old)
return data
except Exception:
return b""
def main():
print("ANSI Escape Code Leak Test")
print("=" * 40)
if not sys.stdout.isatty():
print("ERROR: Run in a real terminal (not CI/piped)")
sys.exit(1)
for turn in range(1, 4):
print(f"\n--- Turn {turn} ---")
# Send DSR query (cursor position request)
# Terminal responds with: ESC [ row ; col R
print(" Sending DSR query (\\x1b[6n)...")
sys.stdout.write("\x1b[6n")
sys.stdout.flush()
time.sleep(0.1)
leaked = check_stdin()
if leaked:
print(f" LEAK DETECTED: {len(leaked)} bytes: {leaked!r}")
else:
print(" No leak (terminal may not respond)")
print("\n" + "=" * 40)
print("If you see ';1R' or similar after this script exits, that's the bug.")
if __name__ == "__main__":
main()Expected output on affected systems:
--- Turn 1 ---
Sending DSR query (\x1b[6n)...
LEAK DETECTED: 7 bytes: b'\x1b[26;1R'
--- Turn 2 ---
LEAK DETECTED: 7 bytes: b'\x1b[31;1R'
Real-World Reproduction (Requires API Key)
#!/usr/bin/env python3
"""Real-world reproduction using SDK agent."""
import os
import sys
# Requires: pip install openhands-sdk openhands-tools
# Set: LLM_API_KEY, optionally LLM_MODEL and LLM_BASE_URL
from openhands.sdk import Agent, Conversation, LLM, Tool
from openhands.tools.terminal import TerminalTool
llm = LLM(
model=os.environ.get("LLM_MODEL", "claude-sonnet-4-20250514"),
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL"),
)
agent = Agent(llm=llm, tools=[Tool(name=TerminalTool.name)])
conversation = Conversation(agent=agent, workspace="/tmp")
# Commands with spinners (like gh) trigger more queries
conversation.send_message("Run: gh pr list --repo OpenHands/openhands --limit 3")
conversation.run()
conversation.close()
# After exit, watch for garbage in your shell promptThe full reproduction script with additional diagnostics is available in the reproduction gist. To run it:
git clone https://gist.github.com/d81954bc0291f77b801397461e72b6a8.git escape-leak-repro
cd escape-leak-repro
export LLM_API_KEY=your-api-key
uv run --with openhands-sdk --with openhands-tools python repro_real.pyRoot Cause
Terminal queries are sent by:
- Rich library (used by SDK visualizer) - for terminal capability detection
- CLI tools with spinners (gh, npm, etc.) - for cursor positioning
- Progress bars and live displays - for screen updates
The terminal responds asynchronously by writing escape sequences to stdin. Without explicit consumption, these responses accumulate.
Proposed Solution
Add a flush_stdin() function that drains pending stdin data using non-blocking reads. Call it:
- After each
agent.step()inLocalConversation.run() - Before rendering in
DefaultConversationVisualizer.on_event() - At process exit via
atexit
See the proposed patch in the original gist.
Related
- Original issue: jpshackelford/lxa#7
- Reproduction gist: https://gist.github.com/jpshackelford/d81954bc0291f77b801397461e72b6a8