223 lines
8.9 KiB
Python
223 lines
8.9 KiB
Python
"""Blocking gateway approval wait for :mod:`tools.approval`.
|
|
|
|
Mirrors the CLI's synchronous ``input()`` flow: the agent thread enqueues a
|
|
pending approval, the gateway notifies the user, and the thread blocks until
|
|
``/approve`` / ``/deny`` resolves it or the approval timeout elapses. Multiple
|
|
threads (parallel subagents, execute_code RPC handlers) can block concurrently
|
|
— each gets its own ``threading.Event``; ``/approve`` resolves the oldest,
|
|
``/approve all`` every pending entry.
|
|
|
|
Queue state (``_gateway_queues``, ``_lock``) is owned by ``tools.approval`` and
|
|
reached through that module at call time so tests patching it keep working.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from tools.interrupt import is_interrupted
|
|
|
|
logger = logging.getLogger("tools.approval")
|
|
|
|
|
|
class _ApprovalEntry:
|
|
"""One pending dangerous-command approval inside a gateway session."""
|
|
__slots__ = ("event", "data", "result", "reason", "acknowledged")
|
|
|
|
def __init__(self, data: dict):
|
|
self.event = threading.Event()
|
|
self.data = dict(data)
|
|
self.data.setdefault("request_id", uuid.uuid4().hex)
|
|
self.acknowledged = False
|
|
self.result: Optional[str] = None # "once"|"session"|"always"|"deny"
|
|
# Free-text reason from ``/deny <reason>`` so the agent can adapt
|
|
# instead of only hearing "denied".
|
|
self.reason: Optional[str] = None
|
|
|
|
|
|
def _hook_payload(approval_data: dict, session_key: str, surface: str) -> dict:
|
|
primary_key = approval_data.get("pattern_key", "")
|
|
return {
|
|
"command": approval_data.get("command", ""),
|
|
"description": approval_data.get("description", ""),
|
|
"pattern_key": primary_key,
|
|
"pattern_keys": list(approval_data.get("pattern_keys", [primary_key])),
|
|
"session_key": session_key,
|
|
"surface": surface,
|
|
}
|
|
|
|
|
|
def _hook_outcome(resolved: bool, choice: Optional[str]) -> str:
|
|
"""Unresolved (timeout) and a None choice both mean the user never answered."""
|
|
return "timeout" if not resolved else (choice or "timeout")
|
|
|
|
|
|
def _poll_event(event: threading.Event, session_key: str, *, interrupt_log: str) -> str:
|
|
"""Wait on *event* until it fires, the turn is interrupted, or approvals.timeout elapses.
|
|
|
|
Returns ``"set"`` | ``"interrupted"`` | ``"timeout"``. Polls in ~1s slices
|
|
so activity heartbeats reach the agent's inactivity tracker every ~10s —
|
|
otherwise the gateway watchdog kills the agent while the user is still
|
|
responding (mirrors ``_wait_for_process()`` cadence). The loop is recorded
|
|
as human-wait time so the concurrent batch deadline excludes it (#79719).
|
|
|
|
``is_interrupted()`` deliberately does NOT distinguish a deliberate /stop
|
|
from a gateway inactivity timeout — both resolve as 'deny' (not
|
|
outcome='timeout'). The per-thread interrupt flag carries no stable
|
|
machine-checkable cause, so a fail-closed deny preserves #8697 semantics;
|
|
changing this needs a dedicated interrupt-cause channel, not string
|
|
matching (#85125).
|
|
"""
|
|
from tools.approval import _get_approval_timeout, human_wait_window
|
|
|
|
timeout = _get_approval_timeout()
|
|
try:
|
|
from tools.environments.base import touch_activity_if_due
|
|
except Exception: # pragma: no cover
|
|
touch_activity_if_due = None
|
|
now = time.monotonic()
|
|
deadline = now + max(timeout, 0)
|
|
activity_state = {"last_touch": now, "start": now}
|
|
with human_wait_window(session_key):
|
|
while True:
|
|
if is_interrupted():
|
|
logger.info(interrupt_log, session_key)
|
|
return "interrupted"
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
return "timeout"
|
|
if event.wait(timeout=min(1.0, remaining)):
|
|
return "set"
|
|
if touch_activity_if_due is not None:
|
|
touch_activity_if_due(activity_state, "waiting for user approval")
|
|
|
|
|
|
def _await_coalesced_leader(session_key: str, leader, approval_data: dict,
|
|
*, surface: str = "gateway"):
|
|
"""Wait on an already-pending identical approval instead of re-prompting.
|
|
|
|
Adopts the leader's decision: ``session``/``always`` → approval (same dict
|
|
shape as a direct resolution; persistence stays the caller's and is
|
|
idempotent across leader and followers); ``deny`` → denial carrying the
|
|
leader's reason; leader timeout / our own deadline → unresolved. ``once``
|
|
returns ``None``: single-use consent covers only the leader's execution,
|
|
so the caller must issue a fresh prompt. Hooks fire with ``coalesced=True``
|
|
so observers see the follower's lifecycle without a duplicate prompt.
|
|
"""
|
|
from tools.approval import _fire_approval_hook
|
|
|
|
payload = _hook_payload(approval_data, session_key, surface)
|
|
_fire_approval_hook("pre_approval_request", **payload, coalesced=True)
|
|
|
|
state = _poll_event(
|
|
leader.event, session_key,
|
|
interrupt_log="Coalesced approval wait interrupted by user signal — "
|
|
"returning deny for session %s",
|
|
)
|
|
if state == "interrupted":
|
|
# Deny only OUR follower; the leader thread handles its own signal.
|
|
choice, resolved = "deny", True
|
|
elif state == "timeout":
|
|
choice, resolved = None, False
|
|
else:
|
|
choice = leader.result
|
|
resolved = choice is not None
|
|
|
|
if choice == "once":
|
|
# The post hook fires for the fresh prompt's own lifecycle, not here.
|
|
return None
|
|
_fire_approval_hook(
|
|
"post_approval_response", **payload,
|
|
choice=_hook_outcome(resolved, choice), coalesced=True,
|
|
)
|
|
return {
|
|
"resolved": resolved,
|
|
"choice": choice,
|
|
"reason": getattr(leader, "reason", None),
|
|
"coalesced": True,
|
|
}
|
|
|
|
|
|
def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict,
|
|
*, surface: str = "gateway") -> dict:
|
|
"""Enqueue *approval_data*, notify the user, and block until resolved or timed out.
|
|
|
|
Shared by the terminal command guard, the execute_code guard, the plugin
|
|
escalation gate, and MCP elicitation. Returns ``{"resolved", "choice",
|
|
"reason"}`` or ``{"resolved": False, "choice": None, "notify_failed": True}``
|
|
when the notify callback raised. Persisting the choice and building the
|
|
tool-facing result stay with the caller.
|
|
|
|
Identical concurrent approvals (same command text + pattern-key set) are
|
|
coalesced: parallel tool calls would otherwise fire N identical prompts
|
|
the user must /approve N times while the agent sits wedged. Followers adopt
|
|
the leader's ``session``/``always``/``deny``/timeout; a ``once`` covers only
|
|
the leader, so the follower falls through to a fresh prompt.
|
|
"""
|
|
from tools import approval as _approval
|
|
|
|
payload = _hook_payload(approval_data, session_key, surface)
|
|
|
|
leader = None
|
|
with _approval._lock:
|
|
for existing in _approval._gateway_queues.get(session_key, []):
|
|
data = existing.data
|
|
if (
|
|
data.get("command") == approval_data.get("command")
|
|
and list(data.get("pattern_keys") or [])
|
|
== list(approval_data.get("pattern_keys") or [])
|
|
):
|
|
leader = existing
|
|
break
|
|
if leader is not None:
|
|
adopted = _await_coalesced_leader(
|
|
session_key, leader, approval_data, surface=surface
|
|
)
|
|
if adopted is not None:
|
|
return adopted
|
|
|
|
entry = _ApprovalEntry(approval_data)
|
|
with _approval._lock:
|
|
_approval._gateway_queues.setdefault(session_key, []).append(entry)
|
|
|
|
def _drop_entry() -> None:
|
|
with _approval._lock:
|
|
queue = _approval._gateway_queues.get(session_key, [])
|
|
if entry in queue:
|
|
queue.remove(entry)
|
|
if not queue:
|
|
_approval._gateway_queues.pop(session_key, None)
|
|
|
|
# Plugins hear about the request before the gateway does (real-time observers).
|
|
_approval._fire_approval_hook("pre_approval_request", **payload)
|
|
|
|
# Bridges sync agent thread → async gateway.
|
|
try:
|
|
notify_cb(dict(entry.data))
|
|
except Exception as exc:
|
|
logger.warning("Gateway approval notify failed: %s", exc)
|
|
_drop_entry()
|
|
_approval._fire_approval_hook(
|
|
"post_approval_response", **payload, choice="notify_failed"
|
|
)
|
|
return {"resolved": False, "choice": None, "notify_failed": True}
|
|
|
|
state = _poll_event(
|
|
entry.event, session_key,
|
|
interrupt_log="Approval wait interrupted by user signal — "
|
|
"returning deny for session %s",
|
|
)
|
|
if state == "interrupted":
|
|
entry.result = "deny"
|
|
entry.event.set()
|
|
resolved = state != "timeout"
|
|
_drop_entry()
|
|
|
|
choice = entry.result
|
|
_approval._fire_approval_hook(
|
|
"post_approval_response", **payload, choice=_hook_outcome(resolved, choice)
|
|
)
|
|
return {"resolved": resolved, "choice": choice, "reason": entry.reason}
|