From 74181fe726e2e2c11e5c3e72032d3043586704db Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:10:23 -0700 Subject: [PATCH] fix: add TTY guard to interactive CLI commands to prevent CPU spin (#3933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When interactive TUI commands are invoked non-interactively (e.g. via the agent's terminal() tool through a subprocess pipe), curses loops spin at 100% CPU and input() calls hang indefinitely. Defense in depth — two layers: 1. Source-level guard in curses_checklist() (curses_ui.py + checklist.py): Returns cancel_returns immediately when stdin is not a TTY. This catches ALL callers automatically, including future code. 2. Command-level guards with clear error messages: - hermes tools (interactive checklist, not list/disable/enable) - hermes setup (interactive wizard) - hermes model (provider/model picker) - hermes whatsapp (pairing setup) - hermes skills config (skill toggle) - hermes mcp configure (tool selection) - hermes uninstall (confirmation prompt) Non-interactive subcommands (hermes tools list, hermes tools enable, hermes mcp add/remove/list/test, hermes skills search/install/browse) remain unaffected. --- hermes_cli/checklist.py | 5 +++++ hermes_cli/curses_ui.py | 6 ++++++ hermes_cli/main.py | 23 +++++++++++++++++++++++ hermes_cli/mcp_config.py | 4 ++++ 4 files changed, 38 insertions(+) diff --git a/hermes_cli/checklist.py b/hermes_cli/checklist.py index 1c56725aa..1a8d9720a 100644 --- a/hermes_cli/checklist.py +++ b/hermes_cli/checklist.py @@ -5,6 +5,7 @@ toggleable list of items. Falls back to a numbered text UI when curses is unavailable (Windows without curses, piped stdin, etc.). """ +import sys from typing import List, Set from hermes_cli.colors import Colors, color @@ -26,6 +27,10 @@ def curses_checklist( The indices the user confirmed as checked. On cancel (ESC/q), returns ``pre_selected`` unchanged. """ + # Safety: return defaults when stdin is not a terminal. + if not sys.stdin.isatty(): + return set(pre_selected) + try: import curses selected = set(pre_selected) diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index dce620b8c..c4b79091e 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -4,6 +4,7 @@ Used by `hermes tools` and `hermes skills` for interactive checklists. Provides a curses multi-select with keyboard navigation, plus a text-based numbered fallback for terminals without curses support. """ +import sys from typing import Callable, List, Optional, Set from hermes_cli.colors import Colors, color @@ -31,6 +32,11 @@ def curses_checklist( if cancel_returns is None: cancel_returns = set(selected) + # Safety: curses and input() both hang or spin when stdin is not a + # terminal (e.g. subprocess pipe). Return defaults immediately. + if not sys.stdin.isatty(): + return cancel_returns + try: import curses chosen = set(selected) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 39d233ad4..f6d7d7c71 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -50,6 +50,23 @@ import sys from pathlib import Path from typing import Optional +def _require_tty(command_name: str) -> None: + """Exit with a clear error if stdin is not a terminal. + + Interactive TUI commands (hermes tools, hermes setup, hermes model) use + curses or input() prompts that spin at 100% CPU when stdin is a pipe. + This guard prevents accidental non-interactive invocation. + """ + if not sys.stdin.isatty(): + print( + f"Error: 'hermes {command_name}' requires an interactive terminal.\n" + f"It cannot be run through a pipe or non-interactive subprocess.\n" + f"Run it directly in your terminal instead.", + file=sys.stderr, + ) + sys.exit(1) + + # Add project root to path PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) @@ -617,6 +634,7 @@ def cmd_gateway(args): def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" + _require_tty("whatsapp") import subprocess from pathlib import Path from hermes_cli.config import get_env_value, save_env_value @@ -803,12 +821,14 @@ def cmd_whatsapp(args): def cmd_setup(args): """Interactive setup wizard.""" + _require_tty("setup") from hermes_cli.setup import run_setup_wizard run_setup_wizard(args) def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" + _require_tty("model") from hermes_cli.auth import ( resolve_provider, AuthError, format_auth_error, ) @@ -2459,6 +2479,7 @@ def cmd_version(args): def cmd_uninstall(args): """Uninstall Hermes Agent.""" + _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall run_uninstall(args) @@ -4131,6 +4152,7 @@ For more help on a command: def cmd_skills(args): # Route 'config' action to skills_config module if getattr(args, 'skills_action', None) == 'config': + _require_tty("skills config") from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) else: @@ -4341,6 +4363,7 @@ For more help on a command: from hermes_cli.tools_config import tools_disable_enable_command tools_disable_enable_command(args) else: + _require_tty("tools") from hermes_cli.tools_config import tools_command tools_command(args) diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0f08e4673..9154ed50a 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -511,6 +511,10 @@ def _interpolate_value(value: str) -> str: def cmd_mcp_configure(args): """Reconfigure which tools are enabled for an existing MCP server.""" + import sys as _sys + if not _sys.stdin.isatty(): + print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr) + _sys.exit(1) name = args.name servers = _get_mcp_servers()