From 033870f2dfc3bd81d5de277058ff214d783daa19 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:56:55 -0500 Subject: [PATCH] fix: harden legacy/tui3 non-tty flows and shutdown stability --- .seeds/issues.jsonl | 3 +++ src/main.py | 39 ++++++++++++++++++++++++++++++- src/seedpass/cli/__init__.py | 15 +++++++++++- src/seedpass/core/manager.py | 21 +++++++++++++++-- src/tests/test_password_prompt.py | 10 ++++++++ src/tests/test_seed_prompt.py | 10 ++++++++ src/tests/test_typer_cli.py | 12 ++++++++++ src/utils/password_prompt.py | 4 ++++ src/utils/seed_prompt.py | 2 ++ 9 files changed, 112 insertions(+), 4 deletions(-) diff --git a/.seeds/issues.jsonl b/.seeds/issues.jsonl index dd169ec2..603aa773 100644 --- a/.seeds/issues.jsonl +++ b/.seeds/issues.jsonl @@ -6,3 +6,6 @@ {"id":"SeedPass-0806","title":"Overstory mail collaboration smoke after codex patch","status":"in_progress","type":"task","priority":2,"createdAt":"2026-03-04T01:45:50.381Z","updatedAt":"2026-03-04T01:45:58.388Z","description":"Verify >=2 agents can exchange ov mail in worktrees after codex runtime add-dir patch"} {"id":"SeedPass-13df","title":"10-agent Overstory launch smoke","status":"in_progress","type":"task","priority":2,"createdAt":"2026-03-04T01:47:19.005Z","updatedAt":"2026-03-04T01:47:29.633Z","description":"Verify 10 codex builders launch concurrently with patched shared-state add-dir"} {"id":"SeedPass-a162","title":"10-agent extra slot","status":"in_progress","type":"task","priority":2,"createdAt":"2026-03-04T01:48:02.794Z","updatedAt":"2026-03-04T01:48:03.781Z","description":"extra slot for 10-agent concurrency smoke"} +{"id":"SeedPass-1d93","title":"Installed beta legacy CLI harness exits with SIGSEGV (-11) after graceful exit","status":"in_progress","type":"bug","priority":1,"createdAt":"2026-03-04T02:36:25.559Z","updatedAt":"2026-03-04T02:56:43.538Z","description":"Repro date: 2026-03-04.\n1) Install beta into isolated HOME:\n HOME=/tmp/seedpass-beta-blackbox-1772591563 PATH=/tmp/seedpass-beta-blackbox-1772591563/.local/bin:$PATH bash scripts/install.sh -b beta -m tui\n2) Run deterministic harness against installed app:\n APP_ROOT=/tmp/seedpass-beta-blackbox-1772591563/.seedpass/app\n PY_BIN=$APP_ROOT/venv/bin/python\n HOME=/tmp/seedpass-beta-blackbox-1772591563 $PY_BIN scripts/ai_tui_agent_test.py --scenario core --repo-root $APP_ROOT --python-bin $PY_BIN --output-dir artifacts/agent_tui_test_installed --keep-home\nObserved: harness scenario passes, transcript ends with 'Exiting the program.', but process exit code is -11 (SIGSEGV).\nEvidence: artifacts/agent_tui_test_installed/20260304T023442Z/report.json and artifacts/agent_tui_test_installed/20260304T023503Z/report.json.\nExpected: process exits with code 0 after graceful exit."} +{"id":"SeedPass-6d26","title":"seedpass tui3 preflight fails in non-interactive runs with EOF before launch","status":"in_progress","type":"bug","priority":2,"createdAt":"2026-03-04T02:36:25.606Z","updatedAt":"2026-03-04T02:56:43.595Z","description":"Repro date: 2026-03-04.\nFresh install, no existing profile:\nHOME=/tmp/seedpass-beta-blackbox-1772591563 PATH=/tmp/seedpass-beta-blackbox-1772591563/.local/bin:$PATH timeout 8s seedpass tui3\nObserved: command prompts initial seed setup and exits with \"TUI v3 preflight failed: EOF when reading a line\" when stdin is non-interactive.\nExpected: explicit non-interactive failure behavior for automation (single concise error), or add a tui3 check mode similar to tui2 --check."} +{"id":"SeedPass-36fb","title":"Non-TTY password prompt for tui3 emits repeated tracebacks and loops on EOF","status":"in_progress","type":"bug","priority":2,"createdAt":"2026-03-04T02:36:25.651Z","updatedAt":"2026-03-04T02:56:43.647Z","description":"Repro date: 2026-03-04.\nWith an existing profile, run in non-tty context:\nHOME=/tmp/seedpass-beta-blackbox-1772591563 PATH=/tmp/seedpass-beta-blackbox-1772591563/.local/bin:$PATH timeout 8s seedpass tui3 > /tmp/tui3_after_bootstrap_probe.txt 2>&1\nObserved: repeated tracebacks from termios fallback and EOF, prompt loops until timeout.\nKey errors include:\n- termios.error: (25, Inappropriate ioctl for device)\n- EOFError: EOF when reading a line\nExpected: single concise non-interactive/TTY-required error and clean exit without traceback spam loops."} diff --git a/src/main.py b/src/main.py index cfdce8b5..256d7fdc 100644 --- a/src/main.py +++ b/src/main.py @@ -1709,6 +1709,15 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in print(colored(f"Error: Failed to restore backup: {e}", "red")) return 1 elif args.command is None and not args.legacy_tui: + if not sys.stdin.isatty(): + print( + colored( + "Error: Interactive startup requires a TTY. " + "Run in a terminal or use non-interactive subcommands.", + "red", + ) + ) + return 1 print("Startup Options:") print("1. Continue") print("2. Restore from backup") @@ -1895,5 +1904,33 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in return 0 +def _exit_as_main_process() -> "NoReturn": + """Execute ``main`` and terminate the process without Python finalizers. + + In some runtime builds with native extensions, interpreter teardown can + intermittently raise SIGSEGV after the app has already completed a normal + shutdown path. Using ``os._exit`` here avoids that nondeterministic + interpreter-finalization crash while preserving the intended exit status. + """ + exit_code = 0 + try: + exit_code = int(main()) + except SystemExit as exc: + code = exc.code + if isinstance(code, int): + exit_code = code + elif code is None: + exit_code = 0 + else: + exit_code = 1 + finally: + try: + sys.stdout.flush() + sys.stderr.flush() + except Exception: + pass + os._exit(exit_code) + + if __name__ == "__main__": - sys.exit(main()) + _exit_as_main_process() diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 187595f8..6cc2ea3c 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -302,8 +302,21 @@ def tui2( @app.command("tui3") -def tui3(ctx: typer.Context) -> None: +def tui3( + ctx: typer.Context, + check: bool = typer.Option( + False, + "--check", + help="Check whether TUI v3 runtime dependencies are available", + ), +) -> None: """Launch the experimental TUI v3 scratch architecture.""" + if check: + from seedpass.tui_v3 import check_tui3_runtime + + typer.echo(json.dumps(check_tui3_runtime(), indent=2, sort_keys=True)) + return + fingerprint = (ctx.obj or {}).get("fingerprint") services, preflight_error = _prime_tui2_service(ctx) if preflight_error is not None: diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index fab4d74d..37144957 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -63,6 +63,7 @@ from utils.password_prompt import ( prompt_existing_password, prompt_new_password, confirm_action, + PasswordPromptError, ) from utils import masked_input, prompt_seed_words from utils.memory_protection import InMemorySecret @@ -731,6 +732,10 @@ class PasswordManager: ) print(colored("Q. Exit", "cyan")) + if not sys.stdin.isatty(): + raise SeedPassError( + "Interactive seed profile selection requires a TTY" + ) choice = input("Select a seed profile by number: ").strip() if choice.lower() in {"q", "quit", "exit"}: raise SeedPassError("Operation cancelled by user") @@ -766,7 +771,14 @@ class PasswordManager: current = self.fingerprint_manager.current_fingerprint except Exception as e: - logger.error(f"Error during seed profile selection: {e}", exc_info=True) + is_interactive_err = isinstance(e, (EOFError, PasswordPromptError)) or ( + isinstance(e, SeedPassError) and "requires a TTY" in str(e) + ) + logger.error( + "Error during seed profile selection: %s", + e, + exc_info=not is_interactive_err, + ) print(colored(f"Error: Failed to select seed profile: {e}", "red")) raise SeedPassError(f"Failed to select seed profile: {e}") from e @@ -1094,7 +1106,12 @@ class PasswordManager: except KeyboardInterrupt: raise except Exception as e: - logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) + is_interactive_err = isinstance(e, (EOFError, PasswordPromptError)) + logger.error( + "Failed to set up EncryptionManager: %s", + e, + exc_info=not is_interactive_err, + ) print(colored(f"Error: Failed to set up encryption: {e}", "red")) if exit_on_fail: raise SeedPassError(f"Failed to set up encryption: {e}") from e diff --git a/src/tests/test_password_prompt.py b/src/tests/test_password_prompt.py index 54eda139..cf80db57 100644 --- a/src/tests/test_password_prompt.py +++ b/src/tests/test_password_prompt.py @@ -28,6 +28,16 @@ def test_prompt_existing_password(monkeypatch): assert password_prompt.prompt_existing_password() == "mypassword" +def test_prompt_existing_password_non_tty(monkeypatch): + monkeypatch.setattr( + password_prompt, + "masked_input", + lambda _prompt: (_ for _ in ()).throw(EOFError("no tty")), + ) + with pytest.raises(password_prompt.PasswordPromptError, match="requires a TTY"): + password_prompt.prompt_existing_password() + + def test_confirm_action_yes_no(monkeypatch): monkeypatch.setattr(builtins, "input", lambda _: "Y") assert password_prompt.confirm_action() diff --git a/src/tests/test_seed_prompt.py b/src/tests/test_seed_prompt.py index 163b7ffd..487dcdc9 100644 --- a/src/tests/test_seed_prompt.py +++ b/src/tests/test_seed_prompt.py @@ -5,6 +5,7 @@ from utils import seed_prompt def test_masked_input_posix_backspace(monkeypatch, capsys): seq = iter(["a", "b", "\x7f", "c", "\n"]) + monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq)) monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0) @@ -36,6 +37,7 @@ def test_masked_input_posix_backspace(monkeypatch, capsys): def test_masked_input_windows_space(monkeypatch, capsys): seq = iter(["x", "y", " ", "z", "\r"]) + monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True) fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq)) monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt) monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False) @@ -49,6 +51,7 @@ def test_masked_input_windows_space(monkeypatch, capsys): def test_masked_input_posix_ctrl_c(monkeypatch): seq = iter(["\x03"]) + monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq)) monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0) @@ -70,6 +73,7 @@ def test_masked_input_posix_ctrl_c(monkeypatch): def test_masked_input_windows_ctrl_c(monkeypatch): seq = iter(["\x03"]) + monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: True) fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq)) monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt) monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False) @@ -93,6 +97,12 @@ def test_prompt_seed_words_valid(monkeypatch): assert result == phrase +def test_masked_input_requires_tty(monkeypatch): + monkeypatch.setattr(seed_prompt.sys.stdin, "isatty", lambda: False) + with pytest.raises(EOFError, match="requires a TTY"): + seed_prompt.masked_input("Enter: ") + + def test_prompt_seed_words_invalid_word(monkeypatch): from mnemonic import Mnemonic diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index acea1ae9..931023ce 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -1092,6 +1092,18 @@ def test_tui2_check(monkeypatch): assert payload["textual_available"] is True +def test_tui3_check(monkeypatch): + monkeypatch.setattr( + cli, + "check_tui2_runtime", + lambda: {"status": "ok", "backend": "textual", "textual_available": True}, + ) + result = runner.invoke(app, ["tui3", "--check"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert "textual_available" in payload + + def test_tui2_unavailable_without_fallback(monkeypatch): monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) diff --git a/src/utils/password_prompt.py b/src/utils/password_prompt.py index 40f1231e..600954d5 100644 --- a/src/utils/password_prompt.py +++ b/src/utils/password_prompt.py @@ -142,6 +142,8 @@ def prompt_new_password(max_retries: int | None = None) -> str: print(colored("\nOperation cancelled by user.", "yellow")) logging.info("Password prompt interrupted by user.") raise PasswordPromptError("Operation cancelled by user") + except EOFError: + raise PasswordPromptError("Interactive password prompt requires a TTY") except Exception as e: logging.error( f"Unexpected error during password prompt: {e}", exc_info=True @@ -204,6 +206,8 @@ def prompt_existing_password( print(colored("\nOperation cancelled by user.", "yellow")) logging.info("Existing password prompt interrupted by user.") raise PasswordPromptError("Operation cancelled by user") + except EOFError: + raise PasswordPromptError("Interactive password prompt requires a TTY") except Exception as e: logging.error( f"Unexpected error during existing password prompt: {e}", diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py index 889ca8ea..216d8455 100644 --- a/src/utils/seed_prompt.py +++ b/src/utils/seed_prompt.py @@ -106,6 +106,8 @@ def _masked_input_posix(prompt: str) -> str: def masked_input(prompt: str) -> str: """Return input from the user while masking typed characters.""" + if not sys.stdin.isatty(): + raise EOFError("Interactive input requires a TTY") func = _masked_input_windows if sys.platform == "win32" else _masked_input_posix pause_console_logging() try: