#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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user