#663 doctor: isatty guard — aipass doctor no longer hangs on non-interactive/blocking stdin. prompt_auto_wire now declines auto-wire when stdin isn't a tty (was: input() blocked forever on a stdin that never EOFs — read as a crash to CI/subprocess callers of the flagship health command). +3 regression tests; output_routing migrated to cli success(). Live-repro proven: blocking non-tty stdin completes instead of hanging.

This commit is contained in:
AIOSAI
2026-07-09 20:50:47 -07:00
parent d2d1adca0a
commit 26a5f3a2ee
3 changed files with 86 additions and 6 deletions
+10
View File
@@ -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/<pid>/status` — Linux-only.
+11 -6
View File
@@ -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)
+65
View File
@@ -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()