fix(approval): close remaining prompt_toolkit deadlock vectors (#15216)
PR #13734 fixed the concurrent-tool-executor vector (ThreadPoolExecutor
workers didn't inherit the CLI's TLS approval callback). Two vectors
remained that could still land in the deadlocking input() fallback:
1. _spawn_background_review spawns a raw threading.Thread with no
approval callback installed, so any dangerous-command guard the
review agent trips falls back to input() -> deadlock against the
parent's prompt_toolkit TUI (same class as delegate_task subagents,
fixed in 023b1bff1 / #15491). Install a _bg_review_auto_deny
callback at thread start, clear on finally.
2. prompt_dangerous_approval's fallback unconditionally spawned a
daemon thread calling input() when approval_callback was None.
That fallback can never succeed under prompt_toolkit because the
user's Enter goes to pt's raw-mode stdin capture. Detect an active
pt Application via get_app_or_none() and fail closed (deny + log)
instead, so future threads that forget to install a callback
degrade gracefully instead of hanging 60s invisibly.
Regression guards:
- tests/run_agent/test_background_review.py verifies the review
worker thread sees a callable auto-deny callback mid-run and that
the slot is cleared in the finally block.
- tests/tools/test_approval.py TestFailClosedUnderPromptToolkit
verifies prompt_dangerous_approval returns 'deny' fast under a
mocked pt Application, and that a real callback still wins over
the guard.
This commit is contained in:
@@ -536,6 +536,33 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
logger.error("Approval callback failed: %s", e, exc_info=True)
|
||||
return "deny"
|
||||
|
||||
# Fail-closed guard: if prompt_toolkit owns the terminal (interactive
|
||||
# CLI session) and no approval callback is registered on this thread,
|
||||
# the input() fallback below would spawn a daemon thread whose read
|
||||
# can never see Enter -- the user's keystrokes go to prompt_toolkit,
|
||||
# not input(), producing an invisible 60s deadlock (issue #15216).
|
||||
# Deny fast and log loudly instead so the caller can surface a real
|
||||
# error to the agent. Any thread that needs interactive approval must
|
||||
# install a callback via tools.terminal_tool.set_approval_callback()
|
||||
# before reaching this point (see delegate_tool.py, run_agent.py
|
||||
# _execute_tool_calls_concurrent / _spawn_background_review for the
|
||||
# established pattern).
|
||||
try:
|
||||
from prompt_toolkit.application.current import get_app_or_none
|
||||
if get_app_or_none() is not None:
|
||||
logger.warning(
|
||||
"Dangerous-command approval requested on a thread with no "
|
||||
"approval callback while prompt_toolkit is active; denying "
|
||||
"to avoid stdin deadlock. command=%r description=%r",
|
||||
command, description,
|
||||
)
|
||||
return "deny"
|
||||
except Exception:
|
||||
# prompt_toolkit not installed, or detection failed -- fall through
|
||||
# to the legacy input() path (safe in non-TUI contexts: scripts,
|
||||
# tests, sshd, etc.).
|
||||
pass
|
||||
|
||||
os.environ["HERMES_SPINNER_PAUSE"] = "1"
|
||||
try:
|
||||
while True:
|
||||
|
||||
Reference in New Issue
Block a user