From 652ebc58995d7dc351f5246e2c6646bea24bd17a Mon Sep 17 00:00:00 2001 From: golldyck <127680312+golldyck@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:09:43 +0300 Subject: [PATCH] fix(console): handle string SystemExit code in _capture_output A dispatched console handler that calls sys.exit("message") or raise SystemExit("message") sets exc.code to a string. int(exc.code or 0) then raises ValueError, which is not a ConsoleCommandError, so it escapes execute()'s handler and crashes the local REPL on an ordinary user mistake (e.g. removing a credential that does not exist). Treat a string exit code as a status-1 failure carrying that message. --- hermes_cli/console_engine.py | 12 ++++++++-- tests/hermes_cli/test_console_engine.py | 30 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 50a4592fc0..627e8abd84 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -57,16 +57,24 @@ def _capture_output(fn: Callable[[], object]) -> str: stdout = io.StringIO() stderr = io.StringIO() code = 0 + message = "" with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): try: result = fn() if isinstance(result, int) and result: raise SystemExit(result) except SystemExit as exc: - code = int(exc.code or 0) + # sys.exit("msg") / raise SystemExit("msg") is the standard non-zero-exit idiom: + # exc.code is the message string, not an int. int() would raise ValueError here, + # which escapes execute()'s ConsoleCommandError handler and crashes the REPL. + if isinstance(exc.code, str): + message = exc.code + code = 1 + else: + code = int(exc.code or 0) text = stdout.getvalue() + stderr.getvalue() if code: - raise ConsoleCommandError(text.strip() or f"Command exited with status {code}") + raise ConsoleCommandError(message.strip() or text.strip() or f"Command exited with status {code}") return text.rstrip() diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py index d9dcada841..bb47042376 100644 --- a/tests/hermes_cli/test_console_engine.py +++ b/tests/hermes_cli/test_console_engine.py @@ -309,3 +309,33 @@ def test_repl_runs_non_interactive_lines_without_prompts(_isolate_hermes_home): assert stderr.getvalue() == "" +def test_capture_output_surfaces_string_exit_code_as_command_error(): + from hermes_cli.console_engine import ConsoleCommandError, _capture_output + + def _boom(): + sys.exit("No credential matching \"nope\".") + + with pytest.raises(ConsoleCommandError) as exc_info: + _capture_output(_boom) + + assert "No credential matching" in str(exc_info.value) + + +def test_capture_output_preserves_integer_exit_code_message(): + from hermes_cli.console_engine import ConsoleCommandError, _capture_output + + with pytest.raises(ConsoleCommandError) as exc_info: + _capture_output(lambda: sys.exit(3)) + + assert "status 3" in str(exc_info.value) + + +def test_execute_handler_string_exit_returns_error_not_crash(_isolate_hermes_home): + result = HermesConsoleEngine().execute( + "auth remove openrouter __no_such_credential__", confirmed=True + ) + + assert result.status == "error" + assert result.output + +