fix(cli): let --fail-on through when no terminal is attached

parse_arguments() rejected --fail-on without -n before main() could switch
to headless, so a CI job running `strix -t x --fail-on high` without -n
stopped at an argument error instead of scanning. The terminal check now
lives in cli_args as terminal_attached(); the parser only enforces the
headless-only flag when a terminal is attached and the TUI would open.
This commit is contained in:
Ahmed Allam 2026-10-04 21:27:54 +00:00 • committed by Ahmed Allam
parent 1ee5c2bcc6
commit fc85e0061e
4 changed files with 61 additions and 19 deletions

View file

@ -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."""

View file

@ -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")

View file

@ -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

View file

@ -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)