From f7d00294b373f31d3da9b205fb7ca0fae030ffdc Mon Sep 17 00:00:00 2001 From: Teknium Date: Fri, 10 Apr 2026 05:24:59 -0700 Subject: [PATCH] fix: flush stdin after curses/terminal menus to prevent escape sequence leakage After curses.wrapper() or simple_term_menu exits, endwin() restores the terminal but does NOT drain the OS input buffer. Leftover escape-sequence bytes from arrow key navigation remain buffered and get silently consumed by the next input()/getpass.getpass() call. This caused a user-reported bug where selecting Z.AI/GLM as provider wrote ^[^[ (two ESC chars) into .env as the API key, because the buffered escape bytes were consumed by getpass before the user could type anything. Fix: add flush_stdin() helper using termios.tcflush(TCIFLUSH) and call it after every curses.wrapper() and simple_term_menu .show() return across all interactive menu sites: - hermes_cli/curses_ui.py (curses_checklist) - hermes_cli/setup.py (_curses_prompt_choice) - hermes_cli/tools_config.py (_prompt_choice) - hermes_cli/auth.py (_prompt_model_selection) - hermes_cli/main.py (3 simple_term_menu usages) --- hermes_cli/auth.py | 2 ++ hermes_cli/curses_ui.py | 23 +++++++++++++++++++++++ hermes_cli/main.py | 6 ++++++ hermes_cli/setup.py | 2 ++ hermes_cli/tools_config.py | 2 ++ 5 files changed, 35 insertions(+) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 36590d617a..6f241a930e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2616,6 +2616,8 @@ def _prompt_model_selection( title=effective_title, ) idx = menu.show() + from hermes_cli.curses_ui import flush_stdin + flush_stdin() if idx is None: return None print() diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index c4b79091e8..a531320fab 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -10,6 +10,28 @@ from typing import Callable, List, Optional, Set from hermes_cli.colors import Colors, color +def flush_stdin() -> None: + """Flush any stray bytes from the stdin input buffer. + + Must be called after ``curses.wrapper()`` (or any terminal-mode library + like simple_term_menu) returns, **before** the next ``input()`` / + ``getpass.getpass()`` call. ``curses.endwin()`` restores the terminal + but does NOT drain the OS input buffer — leftover escape-sequence bytes + (from arrow keys, terminal mode-switch responses, or rapid keypresses) + remain buffered and silently get consumed by the next ``input()`` call, + corrupting user data (e.g. writing ``^[^[`` into .env files). + + On non-TTY stdin (piped, redirected) or Windows, this is a no-op. + """ + try: + if not sys.stdin.isatty(): + return + import termios + termios.tcflush(sys.stdin, termios.TCIFLUSH) + except Exception: + pass + + def curses_checklist( title: str, items: List[str], @@ -131,6 +153,7 @@ def curses_checklist( return curses.wrapper(_draw) + flush_stdin() return result_holder[0] if result_holder[0] is not None else cancel_returns except Exception: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 949f4f808c..615325a135 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1672,6 +1672,8 @@ def _remove_custom_provider(config): title="Select provider to remove:", ) idx = menu.show() + from hermes_cli.curses_ui import flush_stdin + flush_stdin() print() except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): for i, c in enumerate(choices, 1): @@ -1749,6 +1751,8 @@ def _model_flow_named_custom(config, provider_info): title=f"Select model from {name}:", ) idx = menu.show() + from hermes_cli.curses_ui import flush_stdin + flush_stdin() print() if idx is None or idx >= len(models): print("Cancelled.") @@ -1867,6 +1871,8 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): title="Select reasoning effort:", ) idx = menu.show() + from hermes_cli.curses_ui import flush_stdin + flush_stdin() if idx is None: return None print() diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b72cfeef47..60ca76d538 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -338,6 +338,8 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int return curses.wrapper(_curses_menu) + from hermes_cli.curses_ui import flush_stdin + flush_stdin() return result_holder[0] except Exception: return -1 diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9a50a2c5d5..b988f5544a 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -720,6 +720,8 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int: return curses.wrapper(_curses_menu) + from hermes_cli.curses_ui import flush_stdin + flush_stdin() return result_holder[0] except Exception: