diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index 8aa0c5ce..a121a2af 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -342,7 +342,9 @@ Strix Cloud: if args.update: sys.exit(0 if self_update() else 1) - if args.fail_on and not args.non_interactive: + if args.fail_on and not args.non_interactive and terminal_attached(): + # Without a terminal main() switches to headless anyway, so the + # flag is only out of place when the TUI would actually open. parser.error("--fail-on only applies to headless runs; add -n/--non-interactive.") if args.instruction and args.instruction_file: @@ -414,6 +416,13 @@ Strix Cloud: return args +def terminal_attached() -> bool: + """Whether the TUI can take over the terminal: a tty on both ends, not dumb.""" + if os.environ.get("TERM", "").strip().lower() == "dumb": + return False + return all(hasattr(stream, "isatty") and stream.isatty() for stream in (sys.stdin, sys.stdout)) + + class ResumeError(ValueError): """A prior run cannot be resumed as recorded; the message names why.""" diff --git a/strix/interface/main.py b/strix/interface/main.py index 118e7924..cc3a0321 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -7,7 +7,6 @@ import argparse import asyncio import contextlib import logging -import os import sys from pathlib import Path from typing import Any @@ -24,6 +23,7 @@ from strix.interface.cli_args import ( load_resume_state, parse_arguments, resume_run_list_message, + terminal_attached, ) from strix.interface.environment import ( check_docker_installed, @@ -350,13 +350,6 @@ def _print_cli_error(message: str) -> None: HEADLESS_HINT = "If Strix runs without a terminal (CI, nohup, pipes), pass -n to run headless." -def _terminal_attached() -> bool: - """Whether the TUI can take over the terminal: a tty on both ends, not dumb.""" - if os.environ.get("TERM", "").strip().lower() == "dumb": - return False - return all(hasattr(stream, "isatty") and stream.isatty() for stream in (sys.stdin, sys.stdout)) - - def _fall_back_to_headless(args: argparse.Namespace) -> None: """Run headless when there is no terminal for the TUI to attach to. @@ -366,7 +359,7 @@ def _fall_back_to_headless(args: argparse.Namespace) -> None: enter a target, so stop with the fix instead. A bare ``--resume`` is left to the picker, which already explains itself without a terminal. """ - if args.non_interactive or args.resume_picker or _terminal_attached(): + if args.non_interactive or args.resume_picker or terminal_attached(): return if args.needs_setup: report_error("no_terminal_for_setup") diff --git a/tests/test_cli_fail_on.py b/tests/test_cli_fail_on.py index d0525b14..915342e6 100644 --- a/tests/test_cli_fail_on.py +++ b/tests/test_cli_fail_on.py @@ -11,6 +11,7 @@ import pytest cli_main: Any = importlib.import_module("strix.interface.main") +cli_args: Any = importlib.import_module("strix.interface.cli_args") def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None: @@ -104,9 +105,23 @@ def test_parse_fail_on_requires_non_interactive( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: _stub_settings(monkeypatch) + monkeypatch.setattr(cli_args, "terminal_attached", lambda: True) monkeypatch.setattr(sys, "argv", ["strix", "-t", "https://test.com/", "--fail-on", "high"]) with pytest.raises(SystemExit): cli_main.parse_arguments() assert "--fail-on only applies to headless runs" in capsys.readouterr().err + + +def test_parse_fail_on_without_a_terminal_waits_for_the_headless_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _stub_settings(monkeypatch) + monkeypatch.setattr(cli_args, "terminal_attached", lambda: False) + monkeypatch.setattr(sys, "argv", ["strix", "-t", "https://test.com/", "--fail-on", "high"]) + + args = cli_main.parse_arguments() + + assert args.fail_on == "high" + assert args.non_interactive is False diff --git a/tests/test_main_launch.py b/tests/test_main_launch.py index 8a278ac7..21af0583 100644 --- a/tests/test_main_launch.py +++ b/tests/test_main_launch.py @@ -11,6 +11,7 @@ import pytest cli_main: Any = importlib.import_module("strix.interface.main") +cli_args: Any = importlib.import_module("strix.interface.cli_args") report_state_module: Any = importlib.import_module("strix.report.state") interactive: Any = importlib.import_module("strix.interface.interactive") tui_runtime: Any = importlib.import_module("strix.interface.tui.runtime") @@ -42,7 +43,7 @@ def _launch( calls.append("cli") monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) - monkeypatch.setattr(cli_main, "_terminal_attached", lambda: terminal) + monkeypatch.setattr(cli_main, "terminal_attached", lambda: terminal) monkeypatch.setattr("strix.interface.cli.run_cli", run_cli) monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None) monkeypatch.setattr(cli_main, "_print_error_panel", lambda title, _msg: calls.append(title)) @@ -121,7 +122,7 @@ def test_tui_startup_failure_marks_the_prepared_run_failed(monkeypatch: pytest.M raise cli_main.InteractiveSetupUnavailableError("no sidecar") monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) - monkeypatch.setattr(cli_main, "_terminal_attached", lambda: True) + monkeypatch.setattr(cli_main, "terminal_attached", lambda: True) monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None) monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None) monkeypatch.setattr(cli_main, "parse_arguments", lambda: args) @@ -160,6 +161,30 @@ def test_no_terminal_with_a_target_runs_headless_with_a_notice( assert "No terminal attached, running headless (same as -n)." in capsys.readouterr().out +def test_no_terminal_fallback_keeps_the_fail_on_gate(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + report_state = SimpleNamespace( + cleanup=lambda status: calls.append(f"cleanup:{status}"), + vulnerability_reports=[{"id": "vuln-0001", "severity": "high"}], + ) + args = argparse.Namespace( + non_interactive=False, + needs_setup=False, + resume_picker=False, + run_name="run", + fail_on="high", + ) + monkeypatch.setattr(cli_main.posthog, "end", lambda *_args, **_kwargs: None) + monkeypatch.setattr(cli_main.scarf, "end", lambda *_args, **_kwargs: None) + monkeypatch.setattr(report_state_module, "get_global_report_state", lambda: report_state) + + with pytest.raises(SystemExit) as exit_info: + _launch(monkeypatch, needs_setup=False, terminal=False, args=args) + + assert exit_info.value.code == 2 + assert args.non_interactive is True + + def test_no_terminal_without_a_target_stops_with_the_headless_hint( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -167,7 +192,7 @@ def test_no_terminal_without_a_target_stops_with_the_headless_hint( args = argparse.Namespace( non_interactive=False, needs_setup=True, resume_picker=False, run_name=None, fail_on=None ) - monkeypatch.setattr(cli_main, "_terminal_attached", lambda: False) + monkeypatch.setattr(cli_main, "terminal_attached", lambda: False) monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None) monkeypatch.setattr( cli_main, "_print_error_panel", lambda title, msg: panels.append((title, msg)) @@ -191,7 +216,7 @@ def test_no_terminal_leaves_a_bare_resume_to_the_picker(monkeypatch: pytest.Monk args = argparse.Namespace( non_interactive=False, needs_setup=False, resume_picker=True, run_name=None, fail_on=None ) - monkeypatch.setattr(cli_main, "_terminal_attached", lambda: False) + monkeypatch.setattr(cli_main, "terminal_attached", lambda: False) cli_main._fall_back_to_headless(args) @@ -207,18 +232,18 @@ def test_terminal_attached_needs_a_tty_on_both_ends_and_a_real_term( monkeypatch.delenv("TERM", raising=False) monkeypatch.setattr(sys, "stdin", stream(tty=True)) monkeypatch.setattr(sys, "stdout", stream(tty=True)) - assert cli_main._terminal_attached() is True + assert cli_args.terminal_attached() is True monkeypatch.setattr(sys, "stdout", stream(tty=False)) - assert cli_main._terminal_attached() is False + assert cli_args.terminal_attached() is False monkeypatch.setattr(sys, "stdout", stream(tty=True)) monkeypatch.setattr(sys, "stdin", stream(tty=False)) - assert cli_main._terminal_attached() is False + assert cli_args.terminal_attached() is False monkeypatch.setattr(sys, "stdin", stream(tty=True)) monkeypatch.setenv("TERM", "dumb") - assert cli_main._terminal_attached() is False + assert cli_args.terminal_attached() is False def test_tui_process_dying_after_startup_prints_a_panel_instead_of_a_traceback( @@ -235,7 +260,7 @@ def test_tui_process_dying_after_startup_prints_a_panel_instead_of_a_traceback( raise cli_main.InteractiveInterfaceExitedError("Bubble Tea TUI exited with status 1") monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) - monkeypatch.setattr(cli_main, "_terminal_attached", lambda: True) + monkeypatch.setattr(cli_main, "terminal_attached", lambda: True) monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None) monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None) monkeypatch.setattr(cli_main, "parse_arguments", lambda: args)