From a819df970f15fc2e248f12d9760c0ea2b0efa243 Mon Sep 17 00:00:00 2001 From: david girhammar Date: Sat, 18 Apr 2026 02:03:01 +0200 Subject: [PATCH] fix(cli): handle non-tty stdin and EINVAL selector errors gracefully When hermes is launched via a pipe (e.g. `curl ... | bash` installer), fd 0 is a valid file descriptor (so fstat passes) but is not a tty. prompt_toolkit then asks asyncio's kqueue selector to watch fd 0, which fails with OSError: [Errno 22] Invalid argument. The existing fallback handler only matched 'is not registered' and 'Bad file descriptor', so EINVAL slipped through and produced a user-facing traceback. - Pre-flight guard now also requires os.isatty(0), catching the piped-stdin case up front with a helpful message. - Fallback handler also matches 'Invalid argument' and errnos 9 (EBADF) and 22 (EINVAL). - Error messages now mention the `curl | bash` case and suggest opening a fresh terminal. Co-Authored-By: Oz --- cli.py | 34 +++++-- tests/cli/test_cli_stdin_guard.py | 142 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 tests/cli/test_cli_stdin_guard.py diff --git a/cli.py b/cli.py index c0c17babc4cb5..a15de030e3665 100644 --- a/cli.py +++ b/cli.py @@ -10098,14 +10098,22 @@ def _suppress_closed_loop_errors(loop, context): # Validate stdin before launching prompt_toolkit — on macOS with # uv-managed Python, fd 0 can be invalid or unregisterable with the # asyncio selector, causing "KeyError: '0 is not registered'" (#6393). + # Also reject non-tty stdin (e.g. when launched via `curl ... | bash`) + # because kqueue.control() rejects such fds with EINVAL. try: import os as _os _os.fstat(0) + _stdin_is_tty = _os.isatty(0) except OSError: + _stdin_is_tty = False + if not _stdin_is_tty: print( - "Error: stdin (fd 0) is not available.\n" - "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" - "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + "Error: stdin is not an interactive terminal.\n" + "Hermes chat requires a TTY. This usually happens when launched via a\n" + "pipe (e.g. `curl ... | bash`) or with stdin redirected/closed.\n" + "Open a new terminal and run `hermes` directly, or on macOS with\n" + "uv-managed cPython, try reinstalling Python via pyenv or Homebrew,\n" + "then re-run: hermes setup" ) _run_cleanup() self._print_exit_summary() @@ -10127,11 +10135,25 @@ def _suppress_closed_loop_errors(loop, context): except (KeyError, OSError) as _stdin_err: # Catch selector registration failures from broken stdin (#6393). # This is the fallback for cases that slip past the fstat() guard. - if "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err): + # We match on known symptoms: + # - "is not registered" (KeyError from the selector) + # - "Bad file descriptor" (EBADF / errno 9) + # - "Invalid argument" (EINVAL / errno 22, raised by kqueue on + # macOS when fd 0 is not a tty/pipe the kernel will watch) + _err_str = str(_stdin_err) + _errno = getattr(_stdin_err, "errno", None) + if ( + "is not registered" in _err_str + or "Bad file descriptor" in _err_str + or "Invalid argument" in _err_str + or _errno in (9, 22) + ): print( f"\nError: stdin is not usable ({_stdin_err}).\n" - "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" - "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + "This can happen when launched via a pipe (e.g. `curl ... | bash`)\n" + "or with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "Open a new terminal and run `hermes` directly, or reinstall Python\n" + "via pyenv or Homebrew, then re-run: hermes setup" ) else: raise diff --git a/tests/cli/test_cli_stdin_guard.py b/tests/cli/test_cli_stdin_guard.py new file mode 100644 index 0000000000000..30b93656b0601 --- /dev/null +++ b/tests/cli/test_cli_stdin_guard.py @@ -0,0 +1,142 @@ +"""Regression tests for the stdin/tty guard in HermesCLI.run(). + +When hermes is launched via a pipe (e.g. `curl ... | bash` installer), +fd 0 is a valid file descriptor but not a tty. prompt_toolkit → asyncio → +kqueue then raises OSError: [Errno 22] Invalid argument when asked to +watch fd 0. Previously this produced a user-facing traceback. + +These tests validate that: + 1. The pre-flight guard detects non-tty stdin and exits cleanly with a + helpful message. + 2. The fallback exception handler matches EINVAL (errno 22) and + EBADF (errno 9) in addition to the "is not registered" KeyError, + printing a helpful message instead of re-raising. +""" + +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +def _make_cli(**kwargs): + import importlib + + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), \ + patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False): + import cli as _cli_mod + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \ + patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}): + return _cli_mod.HermesCLI(**kwargs), _cli_mod + + +class TestStdinTTYGuard: + """run() should refuse non-tty stdin up front with a helpful message.""" + + def test_non_tty_stdin_exits_cleanly_without_traceback(self, capsys): + cli, _ = _make_cli() + # Short-circuit everything run() does before the tty check so the + # test focuses on guard behavior and doesn't require a real session. + cli._init_agent = MagicMock(return_value=True) + cli.show_banner = MagicMock() + cli._print_exit_summary = MagicMock() + cli._ensure_runtime_credentials = MagicMock(return_value=True) + + # isatty(0) returns False → piped stdin case. + with patch("os.isatty", return_value=False): + # run() should return normally — NOT raise. + cli.run() + + out = capsys.readouterr().out + assert "stdin is not an interactive terminal" in out + assert "curl" in out # helpful hint about the curl|bash case + # Ensure the exit-summary path was invoked (clean shutdown). + cli._print_exit_summary.assert_called_once() + + def test_bad_stdin_fstat_exits_cleanly(self, capsys): + cli, _ = _make_cli() + cli._init_agent = MagicMock(return_value=True) + cli.show_banner = MagicMock() + cli._print_exit_summary = MagicMock() + cli._ensure_runtime_credentials = MagicMock(return_value=True) + + # fstat(0) raises OSError → pre-flight guard triggers. + real_fstat = os.fstat + + def fake_fstat(fd): + if fd == 0: + raise OSError(9, "Bad file descriptor") + return real_fstat(fd) + + with patch("os.fstat", side_effect=fake_fstat): + cli.run() + + out = capsys.readouterr().out + assert "stdin is not an interactive terminal" in out + cli._print_exit_summary.assert_called_once() + + +class TestSelectorErrorMatcher: + """The fallback exception matcher must catch EINVAL / EBADF / KeyError.""" + + def _matcher(self, err): + """Replicate the matcher from cli.py:run() for unit-testing.""" + _err_str = str(err) + _errno = getattr(err, "errno", None) + return ( + "is not registered" in _err_str + or "Bad file descriptor" in _err_str + or "Invalid argument" in _err_str + or _errno in (9, 22) + ) + + def test_matches_einval_from_kqueue(self): + # The exact error produced by prompt_toolkit → asyncio → kqueue + # when fd 0 is piped on macOS. + err = OSError(22, "Invalid argument") + assert self._matcher(err) + + def test_matches_ebadf(self): + err = OSError(9, "Bad file descriptor") + assert self._matcher(err) + + def test_matches_key_error_from_selector(self): + err = KeyError("'0 is not registered'") + assert self._matcher(err) + + def test_does_not_match_unrelated_oserror(self): + err = OSError(2, "No such file or directory") + assert not self._matcher(err) + + def test_does_not_match_unrelated_key_error(self): + err = KeyError("'some_other_key'") + assert not self._matcher(err)