mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 01:11:40 +00:00
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:
parent
1e896b0251
commit
74181fe726
4 changed files with 38 additions and 0 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue