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.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user