fix: add TTY guard to interactive CLI commands to prevent CPU spin (#3933)

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.
This commit is contained in:
Teknium 2026-03-30 08:10:23 -07:00 committed by GitHub
parent 1e896b0251
commit 74181fe726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 38 additions and 0 deletions

View file

@ -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.). curses is unavailable (Windows without curses, piped stdin, etc.).
""" """
import sys
from typing import List, Set from typing import List, Set
from hermes_cli.colors import Colors, color 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), The indices the user confirmed as checked. On cancel (ESC/q),
returns ``pre_selected`` unchanged. returns ``pre_selected`` unchanged.
""" """
# Safety: return defaults when stdin is not a terminal.
if not sys.stdin.isatty():
return set(pre_selected)
try: try:
import curses import curses
selected = set(pre_selected) selected = set(pre_selected)

View file

@ -4,6 +4,7 @@ Used by `hermes tools` and `hermes skills` for interactive checklists.
Provides a curses multi-select with keyboard navigation, plus a Provides a curses multi-select with keyboard navigation, plus a
text-based numbered fallback for terminals without curses support. text-based numbered fallback for terminals without curses support.
""" """
import sys
from typing import Callable, List, Optional, Set from typing import Callable, List, Optional, Set
from hermes_cli.colors import Colors, color from hermes_cli.colors import Colors, color
@ -31,6 +32,11 @@ def curses_checklist(
if cancel_returns is None: if cancel_returns is None:
cancel_returns = set(selected) 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: try:
import curses import curses
chosen = set(selected) chosen = set(selected)

View file

@ -50,6 +50,23 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional 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 # Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
@ -617,6 +634,7 @@ def cmd_gateway(args):
def cmd_whatsapp(args): def cmd_whatsapp(args):
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" """Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
_require_tty("whatsapp")
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from hermes_cli.config import get_env_value, save_env_value from hermes_cli.config import get_env_value, save_env_value
@ -803,12 +821,14 @@ def cmd_whatsapp(args):
def cmd_setup(args): def cmd_setup(args):
"""Interactive setup wizard.""" """Interactive setup wizard."""
_require_tty("setup")
from hermes_cli.setup import run_setup_wizard from hermes_cli.setup import run_setup_wizard
run_setup_wizard(args) run_setup_wizard(args)
def cmd_model(args): def cmd_model(args):
"""Select default model — starts with provider selection, then model picker.""" """Select default model — starts with provider selection, then model picker."""
_require_tty("model")
from hermes_cli.auth import ( from hermes_cli.auth import (
resolve_provider, AuthError, format_auth_error, resolve_provider, AuthError, format_auth_error,
) )
@ -2459,6 +2479,7 @@ def cmd_version(args):
def cmd_uninstall(args): def cmd_uninstall(args):
"""Uninstall Hermes Agent.""" """Uninstall Hermes Agent."""
_require_tty("uninstall")
from hermes_cli.uninstall import run_uninstall from hermes_cli.uninstall import run_uninstall
run_uninstall(args) run_uninstall(args)
@ -4131,6 +4152,7 @@ For more help on a command:
def cmd_skills(args): def cmd_skills(args):
# Route 'config' action to skills_config module # Route 'config' action to skills_config module
if getattr(args, 'skills_action', None) == 'config': if getattr(args, 'skills_action', None) == 'config':
_require_tty("skills config")
from hermes_cli.skills_config import skills_command as skills_config_command from hermes_cli.skills_config import skills_command as skills_config_command
skills_config_command(args) skills_config_command(args)
else: else:
@ -4341,6 +4363,7 @@ For more help on a command:
from hermes_cli.tools_config import tools_disable_enable_command from hermes_cli.tools_config import tools_disable_enable_command
tools_disable_enable_command(args) tools_disable_enable_command(args)
else: else:
_require_tty("tools")
from hermes_cli.tools_config import tools_command from hermes_cli.tools_config import tools_command
tools_command(args) tools_command(args)

View file

@ -511,6 +511,10 @@ def _interpolate_value(value: str) -> str:
def cmd_mcp_configure(args): def cmd_mcp_configure(args):
"""Reconfigure which tools are enabled for an existing MCP server.""" """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 name = args.name
servers = _get_mcp_servers() servers = _get_mcp_servers()