From 0046d170dcd92e776d6aaf329df31d59c6c60426 Mon Sep 17 00:00:00 2001 From: Andrew Ho Date: Tue, 21 Apr 2026 17:01:31 -0700 Subject: [PATCH] fix(agent): propagate approval callbacks to concurrent tool worker threads When tools execute concurrently via ThreadPoolExecutor, worker threads could not see the thread-local approval/sudo callbacks registered by the CLI. This caused dangerous-command prompts to fall back to plain input(), which deadlocks against prompt_toolkit's raw terminal mode. Capture parent-thread callbacks before launching workers, register them locally in each _run_tool thread, and clear them on exit. Mirrors the existing fix pattern from cli.py run_agent() for the main agent worker thread (GHSA-qg5c-hvr5-hjgr / #13617). --- run_agent.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/run_agent.py b/run_agent.py index d662179156f..dd765adca1d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -74,6 +74,12 @@ from model_tools import ( check_toolset_requirements, ) from tools.terminal_tool import cleanup_vm, get_active_env, is_persistent_env +from tools.terminal_tool import ( + set_approval_callback as _set_approval_callback, + set_sudo_password_callback as _set_sudo_password_callback, + _get_approval_callback, + _get_sudo_password_callback, +) from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget from tools.interrupt import set_interrupt as _set_interrupt from tools.browser_tool import cleanup_browser @@ -8649,6 +8655,14 @@ class AIAgent: self._current_tool = tool_names_str self._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}") + # Capture CLI callbacks from the agent thread so worker threads can + # register them locally. Without this, _get_approval_callback() in + # terminal_tool returns None in ThreadPoolExecutor workers, causing + # the dangerous-command prompt to fall back to input() — which + # deadlocks against prompt_toolkit's raw terminal mode (#13617). + _parent_approval_cb = _get_approval_callback() + _parent_sudo_cb = _get_sudo_password_callback() + def _run_tool(index, tool_call, function_name, function_args): """Worker function executed in a thread.""" # Register this worker tid so the agent can fan out an interrupt @@ -8675,6 +8689,18 @@ class AIAgent: set_activity_callback(self._touch_activity) except Exception: pass + # Propagate approval/sudo callbacks to this worker thread. + # Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr). + if _parent_approval_cb is not None: + try: + _set_approval_callback(_parent_approval_cb) + except Exception: + pass + if _parent_sudo_cb is not None: + try: + _set_sudo_password_callback(_parent_sudo_cb) + except Exception: + pass start = time.time() try: result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id, messages=messages) @@ -8697,6 +8723,13 @@ class AIAgent: _set_interrupt(False, _worker_tid) except Exception: pass + # Clear thread-local callbacks so a recycled worker thread + # doesn't hold stale references to a disposed CLI instance. + try: + _set_approval_callback(None) + _set_sudo_password_callback(None) + except Exception: + pass # Start spinner for CLI mode (skip when TUI handles tool progress) spinner = None