From 26a5f3a2eed14df28217be824cb83d59f12332f0 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 9 Jul 2026 20:50:47 -0700 Subject: [PATCH] =?UTF-8?q?#663=20doctor:=20isatty=20guard=20=E2=80=94=20a?= =?UTF-8?q?ipass=20doctor=20no=20longer=20hangs=20on=20non-interactive/blo?= =?UTF-8?q?cking=20stdin.=20prompt=5Fauto=5Fwire=20now=20declines=20auto-w?= =?UTF-8?q?ire=20when=20stdin=20isn't=20a=20tty=20(was:=20input()=20blocke?= =?UTF-8?q?d=20forever=20on=20a=20stdin=20that=20never=20EOFs=20=E2=80=94?= =?UTF-8?q?=20read=20as=20a=20crash=20to=20CI/subprocess=20callers=20of=20?= =?UTF-8?q?the=20flagship=20health=20command).=20+3=20regression=20tests;?= =?UTF-8?q?=20output=5Frouting=20migrated=20to=20cli=20success().=20Live-r?= =?UTF-8?q?epro=20proven:=20blocking=20non-tty=20stdin=20completes=20inste?= =?UTF-8?q?ad=20of=20hanging.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 +++ src/aipass/aipass/apps/modules/doctor_wire.py | 17 +++-- src/aipass/aipass/tests/test_doctor.py | 65 +++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c74eee59..c6980115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ PyPI version — not the changelog header. ### Fixed +- **`aipass doctor` no longer hangs on non-interactive stdin (issue #663).** The + auto-wire `[y/N]` prompt called `input()` with no tty guard, so a caller with a + blocking-but-idle stdin (a script, CI job, or subprocess whose stdin never + sends EOF) hung `doctor` indefinitely — reading as a crash from the flagship + "check my system" command a new user runs first. `prompt_auto_wire` now guards + the prompt with `sys.stdin.isatty()`: a non-tty stdin declines the auto-wire + (prints the manual-wire warning) instead of blocking. Verified against the + exact repro — a blocking non-tty stdin that never EOFs now completes instead of + hanging until killed. Adds 3 regression tests. + - **macOS session lock-out: the boot wrapper can now see tmux sessions on macOS.** `session_boot` decided whether a live Claude session lived inside tmux by walking the process tree through `/proc//status` — Linux-only. diff --git a/src/aipass/aipass/apps/modules/doctor_wire.py b/src/aipass/aipass/apps/modules/doctor_wire.py index cf697fae..81084267 100644 --- a/src/aipass/aipass/apps/modules/doctor_wire.py +++ b/src/aipass/aipass/apps/modules/doctor_wire.py @@ -21,11 +21,12 @@ from __future__ import annotations import json import shutil import subprocess +import sys from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, NamedTuple -from aipass.cli.apps.modules import console +from aipass.cli.apps.modules import console, success from aipass.prax import logger from aipass.aipass.apps.handlers.json import json_handler @@ -206,16 +207,20 @@ def prompt_auto_wire( console.print(f"\n[bold]{', '.join(parts)} missing[/bold]") console.print("[dim]Review details: .claude/hooks/README.md[/dim]") - try: - answer = input("Auto-wire provider settings? [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt) as exc: - logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__) + if not sys.stdin.isatty(): + logger.info("[doctor] non-interactive stdin — auto-wire prompt skipped, treating as decline") answer = "n" + else: + try: + answer = input("Auto-wire provider settings? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt) as exc: + logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__) + answer = "n" if answer in ("y", "yes"): actions = _auto_wire_provider(manifest_path, interactive=True) for action in actions: - console.print(f"[green]✓[/green] {action}") + success(action) return bool(actions) _print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask) diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index 3b9bfeda..3727ad78 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -827,3 +827,68 @@ class TestCheckWireVerify: assert len(results) == 1 assert results[0].glyph == "[yellow]![/yellow]" assert "timed out" in results[0].detail + + +# ============================================================================= +# prompt_auto_wire — non-interactive stdin guard (issue #663) +# ============================================================================= + + +class TestPromptAutoWireIsatty: + """Guard: non-tty stdin must not block on input() (#663).""" + + @staticmethod + def _args() -> dict: + return { + "manifest_path": MagicMock(), + "missing_hooks": ["some_hook"], + "missing_env": [], + "missing_deny": [], + "missing_ask": [], + } + + def test_non_tty_stdin_skips_prompt_and_declines(self) -> None: + """Non-tty stdin must NOT call input() — it declines and warns instead.""" + from aipass.aipass.apps.modules import doctor_wire + + with ( + patch.object(doctor_wire.sys, "stdin") as mock_stdin, + patch("builtins.input") as mock_input, + patch.object(doctor_wire, "_print_manual_wire_warning") as mock_warn, + ): + mock_stdin.isatty.return_value = False + result = doctor_wire.prompt_auto_wire(**self._args()) + + assert result is False + mock_input.assert_not_called() + mock_warn.assert_called_once() + + def test_tty_stdin_prompts_and_respects_decline(self) -> None: + """Tty stdin still prompts; a 'n' answer declines.""" + from aipass.aipass.apps.modules import doctor_wire + + with ( + patch.object(doctor_wire.sys, "stdin") as mock_stdin, + patch("builtins.input", return_value="n") as mock_input, + patch.object(doctor_wire, "_print_manual_wire_warning"), + ): + mock_stdin.isatty.return_value = True + result = doctor_wire.prompt_auto_wire(**self._args()) + + assert result is False + mock_input.assert_called_once() + + def test_tty_stdin_accepts_and_wires(self) -> None: + """Tty stdin with a 'y' answer runs the wire and returns True.""" + from aipass.aipass.apps.modules import doctor_wire + + with ( + patch.object(doctor_wire.sys, "stdin") as mock_stdin, + patch("builtins.input", return_value="y"), + patch.object(doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire, + ): + mock_stdin.isatty.return_value = True + result = doctor_wire.prompt_auto_wire(**self._args()) + + assert result is True + mock_wire.assert_called_once()