From cced4f35094a02b8daf6a04b6d43ee1052fa396d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 18 Aug 2026 05:41:36 -0700 Subject: [PATCH] =?UTF-8?q?Release=20=E2=80=94=20approval-card=20YOLO=20ha?= =?UTF-8?q?ndoff=20fix=20(#6945)=20(#7122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(approvals): handle gateway yolo in webui * fix(approvals): preserve concurrent yolo enables * fix(approvals): honor settled yolo state * fix(approvals): commit yolo after relay * fix(approvals): preserve gateway yolo relay state * fix(approvals): close remaining yolo race windows * fix(approvals): serialize gateway yolo handoff * fix(approvals): linearize yolo disable * fix(approvals): serialize initial yolo relay * fix(approvals): serialize card yolo handoff * fix(yolo): ignore stale card responses * fix(yolo): reject stale session generations * fix(yolo): bind responses to approval owner * fix(yolo): drain pending approvals with exact ownership * fix(approvals): show skip-all busy state * fix: close approval lifecycle races * test: update approval SSE handoff contract * test: include displayed approval owner helpers * fix: linearize yolo transition registration * Release — approval-card YOLO handoff + stale-response ownership fence (#6945) --------- Co-authored-by: dss539 Co-authored-by: n --- CHANGELOG.md | 1 + api/gateway_chat.py | 81 +- api/route_approvals.py | 230 +- api/routes.py | 538 ++++- api/streaming.py | 13 +- docs/advanced-chat-setup.md | 15 + static/commands.js | 44 +- static/messages.js | 257 ++- tests/test_approval_sse.py | 6 +- tests/test_approval_unblock.py | 6 +- tests/test_gateway_yolo_webui_compat.py | 1909 +++++++++++++++++ tests/test_issue467_yolo_mode_toggle.py | 43 + ...t_issue4771_gateway_approval_failure_ui.py | 34 +- tests/test_regressions.py | 15 +- tests/test_sprint30.py | 12 +- 15 files changed, 2992 insertions(+), 212 deletions(-) create mode 100644 tests/test_gateway_yolo_webui_compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index caf237ba..3b4cb379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed +- **Toggling YOLO from an approval card no longer hangs the gateway run or leaks across a session switch.** The approval card's YOLO branch now takes the same single `gateway_yolo_handoff(sid)` as the Runs dispatcher (revalidating the exact mirror after acquisition and holding it through settlement, with no nested-lock path), and the client captures one immutable owner tuple (card-owner session, approval id, load generation) at click time — failing closed without a POST when the visible card is not the active session, and fencing every post-response mutation (card hide, pending clears, stale-clear requery, controls, toast, status) on that exact tuple so a stale card can't project its result into the successor session. Thanks @dss539. (#6945) - **Git operations no longer flash a console window on Windows.** Every git subprocess boundary (agent runtime, rollback, updates, workspace, worktrees) now routes through a shared `windows_hide_flags()` helper that passes `CREATE_NO_WINDOW` on Win32 and stays a no-op on POSIX, so short-lived git commands no longer pop a visible console window on Windows while keeping captured stdout/stderr intact. Thanks @ya1yi2fa3. (#6534) - **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 72ac71f0..c77f4008 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -327,6 +327,73 @@ def _gateway_reasoning_effort_for_request(cfg, *, model=None, model_provider=Non return None +def _gateway_session_yolo_enabled(session_id: str) -> bool: + """Return the WebUI-owned, in-memory YOLO state for a browser session.""" + try: + from tools.approval import is_session_yolo_enabled + + return bool(is_session_yolo_enabled(str(session_id or ""))) + except Exception: + return False + + +def _settle_gateway_run_approval( + session_id: str, + approval_data: dict, + base_url: str, + api_key: str, +) -> tuple[bool, dict | None, int]: + """Auto-approve or mirror one run approval at a session-linearized point.""" + from api.route_approvals import gateway_yolo_handoff, submit_gateway_pending_mirror + + run_id = str(approval_data.get("run_id") or "").strip() + identity_v1 = bool(approval_data.get("_gateway_agent_identity_v1")) + with gateway_yolo_handoff(session_id): + if _gateway_session_yolo_enabled(session_id): + try: + _auto_approve_gateway_run( + base_url, + api_key, + run_id, + approval_data["approval_id"] if identity_v1 else "", + ) + return True, None, 0 + except Exception: + # Fail closed: if remote approval fails, surface the real card + # before allowing a same-session toggle to pass the handoff. + logger.warning( + "WebUI YOLO could not auto-approve run %s; showing approval card", + run_id, + exc_info=True, + ) + head, total = submit_gateway_pending_mirror(session_id, approval_data) + return False, head, total + + +def _auto_approve_gateway_run( + base_url: str, + api_key: str, + run_id: str, + approval_id: str, +) -> None: + """Resolve one Runs API prompt using only the shipped approval contract. + + This is a WebUI-owned compatibility path: the Runs API does not yet expose + session YOLO, so WebUI answers each approval request while its own session + flag is enabled. Native Agent-side YOLO would be preferable because it can + bypass gates before they pause and also covers Agent-owned computer-use + policy; https://github.com/NousResearch/hermes-agent/pull/61946 tracks that + API capability. Until then, do not send speculative fields to the Agent. + """ + from api.runner_client import HttpRunnerClient + + HttpRunnerClient(base_url=base_url, api_key=api_key).respond_approval( + run_id, + approval_id, + "once", + ) + + def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict: """Return redacted Gateway-backed chat configuration status.""" mode = webui_chat_backend_mode(config_data, environ) @@ -615,9 +682,17 @@ def _run_gateway_runs_api_streaming( if approval_data: approval_data["run_id"] = run_id from api.config import gateway_supports_approval_identity_v1 - approval_data["_gateway_agent_identity_v1"] = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key) - from api.route_approvals import submit_gateway_pending_mirror - head, total = submit_gateway_pending_mirror(session_id, approval_data) + identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key) + approval_data["_gateway_agent_identity_v1"] = identity_v1 + auto_approved, head, total = _settle_gateway_run_approval( + session_id, + approval_data, + base_url, + api_key, + ) + if auto_approved: + sse_event = "message" + continue put_gateway_event("approval", {**(head or approval_data), "pending_count": total}) sse_event = "message" continue diff --git a/api/route_approvals.py b/api/route_approvals.py index 6b9111b9..37810978 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -6,6 +6,7 @@ Extracts approval state, not handlers, by design. import queue import threading import uuid +from contextlib import contextmanager from api.session_events import publish_session_list_changed @@ -50,6 +51,95 @@ _GATEWAY_MIRROR_RETAINED = "_gateway_mirror_retained" _GATEWAY_ENTRY_DATA_TOKEN_KEY = "_webui_mirror_token" _GATEWAY_AGENT_IDENTITY_V1 = "_gateway_agent_identity_v1" _gateway_relay_owners: dict[tuple[str, str], str] = {} +_yolo_transition_lock = threading.Lock() +_yolo_transitions: dict[str, dict] = {} +_gateway_yolo_handoff_guard = threading.Lock() +_gateway_yolo_handoffs: dict[str, dict] = {} + + +@contextmanager +def gateway_yolo_handoff(session_key: str): + """Serialize one session's YOLO toggles with gateway approval dispatch.""" + session_key = str(session_key or "").strip() + with _gateway_yolo_handoff_guard: + entry = _gateway_yolo_handoffs.get(session_key) + if entry is None: + entry = {"lock": threading.Lock(), "users": 0} + _gateway_yolo_handoffs[session_key] = entry + entry["users"] += 1 + lock = entry["lock"] + lock.acquire() + try: + yield + finally: + lock.release() + with _gateway_yolo_handoff_guard: + entry["users"] -= 1 + if entry["users"] == 0: + _gateway_yolo_handoffs.pop(session_key, None) + + +def begin_session_yolo_transition(session_key: str) -> object | None: + """Register a pending YOLO enable until one approval relay settles. + + Multiple tabs may relay approvals for different runs in the same session. + Track every in-flight enable intent so one failed relay cannot undo another + successful or explicit enable. Do not publish an unconfirmed enable to the + shared session flag: the gateway stream may only auto-approve later prompts + after a relay succeeds or an explicit enable wins. + """ + session_key = str(session_key or "").strip() + if not session_key: + return None + token = object() + with _yolo_transition_lock: + transition = _yolo_transitions.get(session_key) + if transition is None: + transition = { + "was_enabled": bool(is_session_yolo_enabled(session_key)), + "tokens": set(), + "committed": False, + } + _yolo_transitions[session_key] = transition + transition["tokens"].add(token) + return token + + +def finish_session_yolo_transition(session_key: str, token: object | None, *, succeeded: bool) -> None: + """Settle one pending YOLO enable without exposing or applying stale state.""" + session_key = str(session_key or "").strip() + if not session_key or token is None: + return + with _yolo_transition_lock: + transition = _yolo_transitions.get(session_key) + if transition is None or token not in transition["tokens"]: + return + transition["tokens"].remove(token) + if succeeded: + transition["committed"] = True + # The first confirmed relay commits YOLO immediately. Any remaining + # tokens may fail later but cannot revoke this successful enable. + enable_session_yolo(session_key) + if transition["tokens"]: + return + _yolo_transitions.pop(session_key, None) + if transition["committed"] or transition["was_enabled"]: + enable_session_yolo(session_key) + else: + disable_session_yolo(session_key) + + +def set_session_yolo_enabled(session_key: str, enabled: bool) -> None: + """Apply an explicit YOLO choice and supersede in-flight rollbacks.""" + session_key = str(session_key or "").strip() + if not session_key: + return + with _yolo_transition_lock: + _yolo_transitions.pop(session_key, None) + if enabled: + enable_session_yolo(session_key) + else: + disable_session_yolo(session_key) def _approval_sse_subscribe(session_id: str) -> queue.Queue: @@ -210,12 +300,15 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No live_mirror_present = True continue if live_token: + if entry.get(_GATEWAY_MIRROR_RETAINED): + rebuilt.append(entry) + continue if entry_token: changed = True continue deferred_run_entries.append(entry) continue - if not entry_token: + if entry.get(_GATEWAY_MIRROR_RETAINED) or not entry_token: rebuilt.append(entry) continue changed = True @@ -276,10 +369,16 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No return head, total, changed -def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None: +def _gateway_pending_mirror_locked( + session_key: str, + approval_id: str = "", + run_id: str = "", + mirror_token: str = "", +) -> dict | None: """Return the exact live run-backed mirror under `_lock`.""" approval_id = str(approval_id or "").strip() run_id = str(run_id or "").strip() + mirror_token = str(mirror_token or "").strip() queue = _pending.get(session_key) entries = queue if isinstance(queue, list) else [queue] if queue else [] if approval_id: @@ -296,6 +395,8 @@ def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_ continue if run_id and entry_run_id != run_id: continue + if mirror_token and str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() != mirror_token: + continue if run_id: return entry if matched_entry is not None: @@ -305,19 +406,46 @@ def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_ for entry in entries: if not _is_gateway_mirror_entry(entry) or not str(entry.get("run_id") or "").strip(): continue - if run_id and entry.get("run_id") == run_id: - return entry + if mirror_token and str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() != mirror_token: + continue + if run_id: + if entry.get("run_id") == run_id: + return entry + continue + # With no caller-supplied identity, the queue order is authoritative: + # return the current run-backed projection and let its embedded + # `(approval_id, run_id)` identify the exact relay owner. + return entry return None -def gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None: +def gateway_pending_mirror( + session_key: str, + approval_id: str = "", + run_id: str = "", + mirror_token: str = "", +) -> dict | None: """Return an exact live run-backed mirror for this session.""" with _lock: reconcile_gateway_pending_mirror_locked(session_key) - entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id) + entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id, mirror_token) return dict(entry) if entry else None +def gateway_pending_mirrors(session_key: str) -> list[dict]: + """Return every currently parked run-backed mirror in queue order.""" + with _lock: + reconcile_gateway_pending_mirror_locked(session_key) + queue = _pending.get(session_key) + entries = queue if isinstance(queue, list) else [queue] if queue else [] + return [ + dict(entry) + for entry in entries + if _is_gateway_mirror_entry(entry) + and str(entry.get("run_id") or "").strip() + ] + + def claim_gateway_approval_relay_owner(session_key: str, run_id: str, approval_id: str) -> bool: """Claim the single-flight relay owner for one `(session, run)` pair.""" session_key = str(session_key or "").strip() @@ -348,7 +476,12 @@ def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval _gateway_relay_owners.pop(key, None) -def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> bool: +def retire_gateway_pending_mirror( + session_key: str, + approval_id: str = "", + run_id: str = "", + mirror_token: str = "", +) -> bool: """Retire one approval, or every mirror for a terminal run.""" with _lock: reconcile_gateway_pending_mirror_locked(session_key) @@ -359,7 +492,12 @@ def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_i retained_gateway_queue = gateway_queue gateway_queue_changed = False if approval_id: - match = _gateway_pending_mirror_locked(session_key, approval_id, run_id) + match = _gateway_pending_mirror_locked( + session_key, + approval_id, + run_id, + mirror_token, + ) if match is None and not normalized_run_id: match = next((entry for entry in entries if _is_gateway_mirror_entry(entry) and not str(entry.get("run_id") or "").strip() @@ -523,6 +661,8 @@ def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dic mirror_entry["run_id"] = run_id mirror_entry["approval_id"] = approval_id mirror_entry[_GATEWAY_MIRROR_FLAG] = True + mirror_entry[_GATEWAY_MIRROR_TOKEN] = uuid.uuid4().hex + mirror_entry[_GATEWAY_MIRROR_RETAINED] = True if not _gateway_pending_mirror_locked(session_key, approval_id=approval_id, run_id=run_id): _normalize_pending_queue_locked(session_key).append(mirror_entry) elif not exact_local_entry: @@ -632,6 +772,80 @@ def resolve_gateway_pending_local_no_run_mirror( return True, 1, head, total +def resolve_gateway_pending_local_all( + session_key: str, + choice: str, + reason: str | None = None, +) -> tuple[int, dict | None, int]: + """Resolve every parked local/no-run approval without touching remote runs.""" + targets = [] + removed_pending = False + with _lock: + reconcile_gateway_pending_mirror_locked(session_key) + + gateway_queue = _gateway_queues.get(session_key) or [] + retained_gateway_queue = [] + for entry in gateway_queue: + data = getattr(entry, "data", None) or {} + if str(data.get("run_id") or "").strip(): + retained_gateway_queue.append(entry) + else: + targets.append(entry) + if retained_gateway_queue: + _gateway_queues[session_key] = retained_gateway_queue + else: + _gateway_queues.pop(session_key, None) + + queue = _pending.get(session_key) + entries = queue if isinstance(queue, list) else [queue] if queue else [] + retained_pending = [ + entry + for entry in entries + if _is_gateway_mirror_entry(entry) + and str(entry.get("run_id") or "").strip() + ] + removed_pending = len(retained_pending) != len(entries) + if retained_pending: + _pending[session_key] = retained_pending + else: + _pending.pop(session_key, None) + + head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key) + _approval_sse_notify_locked(session_key, head, total) + + for entry in targets: + entry.result = choice + if reason: + entry.reason = reason + entry.event.set() + if targets or removed_pending: + publish_session_list_changed("attention_resolved") + return len(targets), head, total + + +def settle_gateway_pending_local_notification( + session_key: str, + approval: dict, +) -> tuple[bool, dict | None, int]: + """Auto-resolve or publish one local approval at the YOLO handoff boundary. + + The Agent adds its blocking entry before invoking WebUI's notify callback. + Serialize that callback with session YOLO commit/disable so a waiter arriving + after a drain snapshot cannot be parked behind an already-committed enable. + Run-backed approvals stay on the Runs API path and are never resolved here. + """ + with gateway_yolo_handoff(session_key): + run_id = str((approval or {}).get("run_id") or "").strip() + if not run_id and is_session_yolo_enabled(session_key): + _resolved, head, total = resolve_gateway_pending_local_all( + session_key, + "once", + ) + return True, head, total + head, total = submit_gateway_pending_mirror(session_key, approval) + return False, head, total + + def submit_pending(session_key: str, approval: dict) -> None: """Append a pending approval to the per-session queue. diff --git a/api/routes.py b/api/routes.py index 492b522d..b2ce1c9f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9934,16 +9934,23 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp _approval_sse_unsubscribe, _approval_sse_notify_locked, _approval_sse_notify, + _GATEWAY_AGENT_IDENTITY_V1, _GATEWAY_MIRROR_FLAG, _GATEWAY_MIRROR_TOKEN, _gateway_mirror_entry_token, + gateway_yolo_handoff, + begin_session_yolo_transition, claim_gateway_approval_relay_owner, + finish_session_yolo_transition, gateway_pending_mirror, + gateway_pending_mirrors, release_gateway_approval_relay_owner, retire_gateway_pending_mirror, reconcile_gateway_pending_mirror_locked, resolve_gateway_pending_local, + resolve_gateway_pending_local_all, resolve_gateway_pending_local_no_run_mirror, + set_session_yolo_enabled, submit_gateway_pending_mirror, submit_pending, ) @@ -15593,22 +15600,15 @@ def handle_post(handler, parsed) -> bool: require(body, "session_id") except ValueError as e: return bad(handler, str(e)) - sid = body["session_id"] + sid = str(body["session_id"] or "").strip() enabled = bool(body.get("enabled", True)) - if enabled: - enable_session_yolo(sid) - # Also resolve any pending approvals for this session so the - # agent doesn't stay stuck waiting on an already-dismissed card. - try: - from tools.approval import _pending as _p, _lock as _l - with _l: - _p.pop(sid, None) - except Exception: - pass - resolve_gateway_approval(sid, "once", resolve_all=True) - else: - disable_session_yolo(sid) - return j(handler, {"ok": True, "yolo_enabled": enabled}) + if not enabled: + with gateway_yolo_handoff(sid): + set_session_yolo_enabled(sid, False) + return j(handler, {"ok": True, "yolo_enabled": bool(is_session_yolo_enabled(sid))}) + + payload, status = _enable_session_yolo_and_release_pending(sid, choice="once") + return j(handler, payload, status=status) if parsed.path == "/api/btw": return _handle_btw(handler, body) @@ -24677,6 +24677,269 @@ _GATEWAY_APPROVAL_RELAY_IN_PROGRESS = ( ) +def _gateway_approval_failure( + sid: str, + choice: str, + *, + code: str, + error: str, + status: int, + enable_yolo: bool, + relayed: bool = False, +) -> tuple[dict, int]: + """Build a failed relay response with authoritative session-YOLO state.""" + payload = { + "ok": False, + "choice": choice, + "relayed": relayed, + "code": code, + "error": error, + } + if enable_yolo: + payload["yolo_enabled"] = bool(is_session_yolo_enabled(sid)) + return payload, status + + +def _relay_gateway_run_approval( + sid: str, + mirror: dict, + choice: str, + *, + enable_yolo: bool, +) -> tuple[dict, int]: + """Relay one exact run-backed mirror under the shared `(session, run)` owner. + + The mirror remains actionable unless the remote Runs API confirms success. + Both the approval-card endpoint and the ordinary session-YOLO endpoint use + this chokepoint so one tab cannot retire another tab's parked remote run. + """ + from api.config import gateway_supports_approval_identity_v1, get_config as _get_config + from api.gateway_chat import _gateway_api_key, _gateway_base_url + from api.runner_client import HttpRunnerClient, RunnerClientError + + run_id = str(mirror.get("run_id") or "").strip() + approval_id = str(mirror.get("approval_id") or "").strip() + mirror_token = str(mirror.get(_GATEWAY_MIRROR_TOKEN) or "").strip() + if not run_id or not approval_id: + return _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=enable_yolo, + ) + if not claim_gateway_approval_relay_owner(sid, run_id, approval_id): + return _gateway_approval_failure( + sid, + choice, + code="gateway_approval_in_progress", + error=_GATEWAY_APPROVAL_RELAY_IN_PROGRESS, + status=409, + enable_yolo=enable_yolo, + ) + + try: + current_mirror = gateway_pending_mirror( + sid, + approval_id=approval_id, + run_id=run_id, + mirror_token=mirror_token, + ) + if not current_mirror: + return _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=enable_yolo, + ) + + base_url = _gateway_base_url(_get_config()) + api_key = _gateway_api_key() + identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and ( + gateway_supports_approval_identity_v1(base_url, api_key) + ) + if not identity_v1: + run_head = gateway_pending_mirror(sid, run_id=run_id) + if not run_head or str(run_head.get("approval_id") or "").strip() != approval_id: + return _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=enable_yolo, + ) + + yolo_transition = begin_session_yolo_transition(sid) if enable_yolo else None + relay_error = None + relay_succeeded = False + try: + HttpRunnerClient(base_url=base_url, api_key=api_key).respond_approval( + run_id, + approval_id if identity_v1 else "", + choice, + ) + relay_succeeded = True + except (RunnerClientError, ValueError) as exc: + relay_error = str(exc) + finally: + finish_session_yolo_transition(sid, yolo_transition, succeeded=relay_succeeded) + + if relay_error is not None: + return _gateway_approval_failure( + sid, + choice, + code="gateway_approval_relay_failed", + error=relay_error, + status=502, + enable_yolo=enable_yolo, + relayed=True, + ) + + # The outbound relay resumes the remote run. Retire the local projection + # only after that succeeds, then settle any matching in-process mirror. + _resolve_approval_legacy(sid, approval_id, choice, run_id=run_id) + retire_gateway_pending_mirror( + sid, + approval_id=approval_id, + run_id=run_id, + mirror_token=mirror_token, + ) + return { + "ok": True, + "choice": choice, + "relayed": True, + **( + {"yolo_enabled": bool(is_session_yolo_enabled(sid))} + if enable_yolo + else {} + ), + }, 200 + finally: + release_gateway_approval_relay_owner(sid, run_id, approval_id) + + +def _pending_approval_owner_state( + sid: str, + approval_id: str, + run_id: str = "", + mirror_token: str = "", +) -> tuple[bool, bool]: + """Return `(exact_owner_exists, any_pending_exists)` under queue authority.""" + approval_id = str(approval_id or "").strip() + run_id = str(run_id or "").strip() + mirror_token = str(mirror_token or "").strip() + with _lock: + reconcile_gateway_pending_mirror_locked(sid) + queue = _pending.get(sid) + entries = queue if isinstance(queue, list) else [queue] if queue else [] + exact = False + for entry in entries: + if not isinstance(entry, dict) or str(entry.get("approval_id") or "") != approval_id: + continue + entry_run_id = str(entry.get("run_id") or "").strip() + entry_mirror_token = str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() + if run_id and entry_run_id != run_id: + continue + if mirror_token and entry_mirror_token != mirror_token: + continue + if (run_id or mirror_token) and not entry.get(_GATEWAY_MIRROR_FLAG): + continue + exact = True + break + return exact, bool(entries or _gateway_queues.get(sid)) + + +def _enable_session_yolo_and_release_pending( + sid: str, + *, + choice: str, + approval_id: str = "", + run_id: str = "", + mirror_token: str = "", + include_choice: bool = False, +) -> tuple[dict, int]: + """Relay every parked remote approval, drain local waiters, then commit YOLO.""" + approval_id = str(approval_id or "").strip() + run_id = str(run_id or "").strip() + mirror_token = str(mirror_token or "").strip() + has_exact_remote_owner = bool(run_id or mirror_token) + if has_exact_remote_owner and (not approval_id or not run_id or not mirror_token): + return _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=True, + ) + + yolo_transition = None + try: + with gateway_yolo_handoff(sid): + yolo_transition = begin_session_yolo_transition(sid) + stale_cleared = False + if approval_id: + exact_owner, any_pending = _pending_approval_owner_state( + sid, + approval_id, + run_id, + mirror_token, + ) + if not exact_owner and (has_exact_remote_owner or any_pending): + return _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=True, + ) + stale_cleared = not exact_owner + + run_mirrors = gateway_pending_mirrors(sid) + relayed = 0 + for mirror in run_mirrors: + relay_payload, relay_status = _relay_gateway_run_approval( + sid, + mirror, + choice, + enable_yolo=False, + ) + if relay_status != 200 or not relay_payload.get("ok"): + finish_session_yolo_transition( + sid, + yolo_transition, + succeeded=False, + ) + yolo_transition = None + return { + **relay_payload, + "yolo_enabled": bool(is_session_yolo_enabled(sid)), + }, relay_status + relayed += 1 + + resolve_gateway_pending_local_all( + sid, + choice, + ) + finish_session_yolo_transition(sid, yolo_transition, succeeded=True) + yolo_transition = None + return { + "ok": True, + "yolo_enabled": bool(is_session_yolo_enabled(sid)), + **({"choice": choice} if include_choice or relayed else {}), + **({"relayed": True} if relayed else {}), + **({"stale_cleared": True} if stale_cleared else {}), + }, 200 + finally: + if yolo_transition is not None: + finish_session_yolo_transition(sid, yolo_transition, succeeded=False) + + def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool: with _lock: reconcile_gateway_pending_mirror_locked(sid) @@ -24726,6 +24989,55 @@ def _handle_approval_respond(handler, body): if choice not in ("once", "session", "always", "deny"): return bad(handler, f"Invalid choice: {choice}") approval_id = body.get("approval_id", "") + enable_yolo = body.get("yolo") is True + requested_run_id = str(body.get("run_id") or "").strip() + requested_mirror_token = str(body.get("mirror_token") or "").strip() + + if enable_yolo: + payload, status = _enable_session_yolo_and_release_pending( + sid, + choice=choice, + approval_id=approval_id, + run_id=requested_run_id, + mirror_token=requested_mirror_token, + include_choice=True, + ) + return j(handler, payload, status=status) + + if requested_run_id or requested_mirror_token: + if not approval_id or not requested_run_id or not requested_mirror_token: + relay_payload, relay_status = _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=False, + ) + return j(handler, relay_payload, status=relay_status) + exact_mirror = gateway_pending_mirror( + sid, + approval_id=approval_id, + run_id=requested_run_id, + mirror_token=requested_mirror_token, + ) + if exact_mirror is None: + relay_payload, relay_status = _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=False, + ) + return j(handler, relay_payload, status=relay_status) + relay_payload, relay_status = _relay_gateway_run_approval( + sid, + exact_mirror, + choice, + enable_yolo=False, + ) + return j(handler, relay_payload, status=relay_status) # Gateway relay: forward choice to the runs API when session has an active run, # or recover the run_id from the mirrored gateway approval entry if the @@ -24733,12 +25045,9 @@ def _handle_approval_respond(handler, body): try: from api.gateway_chat import ( _STREAM_RUN_IDS, - _gateway_base_url, - _gateway_api_key, webui_gateway_chat_enabled, ) - from api.config import get_config as _get_config, gateway_supports_approval_identity_v1 - from api.route_approvals import _GATEWAY_AGENT_IDENTITY_V1 + from api.config import get_config as _get_config s = get_session(sid) _candidate_run_id = None if s is not None: @@ -24807,17 +25116,15 @@ def _handle_approval_respond(handler, body): if local_match: _candidate_run_id = None if approval_id and not local_match and same_run_stale_without_token: - return j( - handler, - { - "ok": False, - "choice": choice, - "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE, - }, + relay_payload, relay_status = _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, status=409, + enable_yolo=enable_yolo, ) + return j(handler, relay_payload, status=relay_status) matched_mirror = ( gateway_pending_mirror(sid, approval_id=approval_id, run_id=_candidate_run_id) if approval_id and not local_match @@ -24828,88 +25135,61 @@ def _handle_approval_respond(handler, body): if local_match: _candidate_run_id = None elif run_backed_gateway_matches > 1 and not _candidate_run_id: - return j( - handler, - { - "ok": False, - "choice": choice, - "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE, - }, - status=409, - ) - if _run_id: - from api.runner_client import HttpRunnerClient, RunnerClientError - _cfg = _get_config() - _base = _gateway_base_url(_cfg) - _key = _gateway_api_key() - claimed_approval_id = str(matched_mirror.get("approval_id") or "").strip() - if not claim_gateway_approval_relay_owner(sid, _run_id, claimed_approval_id): - return j( - handler, - { - "ok": False, - "choice": choice, - "relayed": False, - "code": "gateway_approval_in_progress", - "error": _GATEWAY_APPROVAL_RELAY_IN_PROGRESS, - }, - status=409, - ) - try: - current_mirror = gateway_pending_mirror( + relay_payload, relay_status = _gateway_approval_failure( sid, - approval_id=claimed_approval_id, - run_id=_run_id, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=enable_yolo, ) - if not current_mirror: - return j( - handler, - { - "ok": False, - "choice": choice, - "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE, - }, - status=409, + return j(handler, relay_payload, status=relay_status) + if _run_id: + if enable_yolo: + # The visible card path must serialize the same session-wide + # handoff as the ordinary /api/session/yolo route and the Runs + # stream. Revalidate the exact mirror after acquiring it so a + # later approval cannot be parked while this relay commits YOLO. + with gateway_yolo_handoff(sid): + current_mirror = gateway_pending_mirror( + sid, + approval_id=approval_id, + run_id=_run_id, ) - identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and gateway_supports_approval_identity_v1(_base, _key) - if not identity_v1: - run_head = gateway_pending_mirror(sid, run_id=_run_id) - if not run_head or str(run_head.get("approval_id") or "").strip() != claimed_approval_id: - return j( - handler, - { - "ok": False, - "choice": choice, - "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE, - }, + if current_mirror is None: + relay_payload, relay_status = _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, status=409, + enable_yolo=True, ) - matched_mirror = current_mirror - try: - HttpRunnerClient(base_url=_base, api_key=_key).respond_approval( - _run_id, matched_mirror["approval_id"] if identity_v1 else "", choice - ) - except (RunnerClientError, ValueError) as exc: - return j(handler, {"ok": False, "choice": choice, "relayed": True, "error": str(exc)}, status=502) - # The outbound relay only resumes the remote run; the local mirror - # still needs the same cleanup path so the parked entry, mirrored - # card, and agent signal all settle here too. - cleanup_approval_id = matched_mirror["approval_id"] - _resolve_approval_legacy(sid, cleanup_approval_id, choice, run_id=_run_id) - retire_gateway_pending_mirror(sid, approval_id=cleanup_approval_id, run_id=_run_id) - return j(handler, {"ok": True, "choice": choice, "relayed": True}) - finally: - release_gateway_approval_relay_owner(sid, _run_id, claimed_approval_id) + else: + relay_payload, relay_status = _relay_gateway_run_approval( + sid, + current_mirror, + choice, + enable_yolo=True, + ) + else: + relay_payload, relay_status = _relay_gateway_run_approval( + sid, + matched_mirror or {}, + choice, + enable_yolo=False, + ) + return j(handler, relay_payload, status=relay_status) if _candidate_run_id: - return j(handler, {"ok": False, "choice": choice, "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409) + relay_payload, relay_status = _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=enable_yolo, + ) + return j(handler, relay_payload, status=relay_status) # A no-run mirror is local visibility state only. Resolve it only while # the exact parked producer still exists; otherwise keep the card live # and fail closed instead of claiming success. @@ -24918,11 +25198,28 @@ def _handle_approval_respond(handler, body): sid, approval_id, choice ) if handled_no_run_mirror and resolved_count == 1: - return j(handler, {"ok": True, "choice": choice, "local_retired": True}) + if enable_yolo: + set_session_yolo_enabled(sid, True) + return j(handler, { + "ok": True, + "choice": choice, + "local_retired": True, + **( + {"yolo_enabled": bool(is_session_yolo_enabled(sid))} + if enable_yolo + else {} + ), + }) if handled_no_run_mirror: - return j(handler, {"ok": False, "choice": choice, "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409) + relay_payload, relay_status = _gateway_approval_failure( + sid, + choice, + code="gateway_run_unavailable", + error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE, + status=409, + enable_yolo=enable_yolo, + ) + return j(handler, relay_payload, status=relay_status) except Exception: pass # fall through to local approval path @@ -24954,8 +25251,29 @@ def _handle_approval_respond(handler, body): # card instead of dead-ending. When something IS still pending, keep # the protective ok:false. `stale_cleared` lets the frontend log/branch # without showing an error toast. - return j(handler, {"ok": True, "choice": choice, "stale_cleared": True}) - return j(handler, {"ok": ok, "choice": choice}) + if enable_yolo: + set_session_yolo_enabled(sid, True) + return j(handler, { + "ok": True, + "choice": choice, + "stale_cleared": True, + **( + {"yolo_enabled": bool(is_session_yolo_enabled(sid))} + if enable_yolo + else {} + ), + }) + if ok and enable_yolo: + set_session_yolo_enabled(sid, True) + return j(handler, { + "ok": ok, + "choice": choice, + **( + {"yolo_enabled": bool(is_session_yolo_enabled(sid))} + if ok and enable_yolo + else {} + ), + }) def _resolve_clarify_legacy(sid: str, clarify_id: str, response: str) -> bool: diff --git a/api/streaming.py b/api/streaming.py index 10482a42..b7e2f565 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -8732,22 +8732,27 @@ def _run_agent_streaming( try: try: from api.route_approvals import ( - submit_gateway_pending_mirror as _submit_pending_for_polling, + settle_gateway_pending_local_notification as _settle_pending_for_polling, retire_gateway_pending_mirror as _retire_gateway_pending_mirror, ) def _cleanup_gateway_pending_mirror(): _retire_gateway_pending_mirror(session_id) except ImportError: - _submit_pending_for_polling = None + _settle_pending_for_polling = None _cleanup_gateway_pending_mirror = None from tools.approval import ( register_gateway_notify as _reg_notify, unregister_gateway_notify as _unreg_notify, ) def _approval_notify_cb(approval_data): - if _submit_pending_for_polling is not None: + if _settle_pending_for_polling is not None: try: - head, total = _submit_pending_for_polling(session_id, approval_data) + auto_resolved, head, total = _settle_pending_for_polling( + session_id, + approval_data, + ) + if auto_resolved and head is None: + return approval_data = {**(head or approval_data), "pending_count": total} except Exception: logger.warning("Failed to mirror approval into WebUI polling state", exc_info=True) diff --git a/docs/advanced-chat-setup.md b/docs/advanced-chat-setup.md index 2a4d37ad..a4cdf22c 100644 --- a/docs/advanced-chat-setup.md +++ b/docs/advanced-chat-setup.md @@ -102,6 +102,21 @@ HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \ Use this when the connected gateway advertises approval support and you want tool approval cards to appear in WebUI. Without `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`, gateway chat stays on the legacy chat-completions transport and approval-capable commands can remain pending in the agent without a WebUI approval card. +When YOLO is enabled for a gateway-backed browser session, WebUI approves every +approval already parked for that session: Runs API prompts are relayed by their +exact `run_id` and mirror token, and local/no-run waiters are all released. It +then automatically answers later Runs API approval requests while the WebUI +session flag remains active. The flag is committed only after every currently +parked remote relay succeeds; a later prompt that races that unconfirmed drain +remains visible instead of being speculatively auto-approved. The handoff is +also shared with local approval admission: a local waiter arriving after +the current drain snapshot waits for the same session handoff and is released +immediately if YOLO has committed, rather than being parked behind an enabled +session. This is client-managed compatibility behavior: the current Runs API has +no session-YOLO toggle, so a request briefly reaches the approval boundary before WebUI answers +it, and Agent-owned policy such as unrestricted computer-use mode is unchanged. +Native API session YOLO is tracked in [Hermes Agent PR #61946](https://github.com/NousResearch/hermes-agent/pull/61946). + `HERMES_WEBUI_CHAT_BACKEND` is intentionally strict: only `gateway`, `api_server`, or `api-server` enable the bridge. Generic truthy values such as `1` or `true` are ignored so existing deployments do not change execution diff --git a/static/commands.js b/static/commands.js index 74e5eb49..e322c1d8 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1941,22 +1941,50 @@ function cmdVoice(){ async function cmdYolo(){ const sid=S.session&&S.session.session_id; if(!sid){showToast(t('yolo_no_session'));return;} + const generation=_loadSessionGeneration; + const viewIsCurrent=()=>!!( + S.session&&S.session.session_id===sid&&_loadSessionGeneration===generation + ); + let approvalOwner=null; try{ - // Check current state first to toggle + // Check current state first to toggle. const status=await api('/api/session/yolo?session_id='+encodeURIComponent(sid)); + if(!viewIsCurrent())return; const enable=!status.yolo_enabled; - await api('/api/session/yolo',{ + // A visible approval must belong to this exact session load before any + // command handler may POST through it. Otherwise fail closed. + const card=$('approvalCard'); + if(card&&card.classList.contains('visible')){ + approvalOwner=typeof _captureApprovalResponseOwner==='function' + ?_captureApprovalResponseOwner() + :null; + if(!approvalOwner)return; + if(enable&&typeof toggleYoloFromApproval==='function'){ + await toggleYoloFromApproval(); + return; + } + } + const result=await api('/api/session/yolo',{ method:'POST', body:JSON.stringify({session_id:sid,enabled:enable}), }); - _yoloEnabled=enable; + if(!viewIsCurrent()||(approvalOwner&&!_approvalResponseOwnerIsCurrent(approvalOwner)))return; + const settled=(result&&typeof result.yolo_enabled==='boolean')?result.yolo_enabled:enable; + _yoloEnabled=settled; _updateYoloPill(); - showToast(enable?t('yolo_enabled'):t('yolo_disabled')); - if(enable){ - // Dismiss any visible approval card - hideApprovalCard(true); + showToast(settled?t('yolo_enabled'):t('yolo_disabled')); + }catch(e){ + if(!viewIsCurrent()||(approvalOwner&&!_approvalResponseOwnerIsCurrent(approvalOwner)))return; + let errorPayload=null; + if(e&&typeof e.body==='string'){ + try{errorPayload=JSON.parse(e.body);}catch(_){} } - }catch(e){showToast('YOLO: '+e.message);} + if(errorPayload&&typeof errorPayload.yolo_enabled==='boolean'){ + _yoloEnabled=errorPayload.yolo_enabled; + _updateYoloPill(); + } + showToast('YOLO: '+((errorPayload&&(errorPayload.error||errorPayload.message))||e.message)); + } } // ── Branch / fork command ── diff --git a/static/messages.js b/static/messages.js index 49c07d15..d6972749 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7264,18 +7264,9 @@ function _updateYoloPill() { } async function toggleYoloFromApproval() { - const sid = S.session && S.session.session_id; - if (!sid) return; - try { - await api('/api/session/yolo', { - method: 'POST', - body: JSON.stringify({ session_id: sid, enabled: true }), - }); - _yoloEnabled = true; - _updateYoloPill(); - hideApprovalCard(true); - showToast(t('yolo_enabled')); - } catch (e) { showToast('YOLO: ' + e.message); } + const owner = _captureApprovalResponseOwner(); + if (!owner) return false; + return !!(await respondApproval('once', {yolo: true, owner})); } // ── Approval polling ── @@ -7337,8 +7328,13 @@ function hideApprovalCard(force=false) { return; } } + const preserveDisplayedOwner = _approvalOwnerIdentityMatches( + _approvalDisplayedOwner, + _approvalResponding, + ); _approvalSessionId = null; _resetApprovalCardState(); + if (!preserveDisplayedOwner) _approvalDisplayedOwner = null; card.classList.remove("visible"); card.classList.remove("collapsed"); _setPromptFlyoutHidden(card, true); @@ -7352,6 +7348,8 @@ let _approvalSessionId = null; let _approvalCurrentId = null; // approval_id of the card currently shown let _approvalPendingBySession = new Map(); let _approvalResponding = null; +let _approvalClearedOwner = null; +let _approvalDisplayedOwner = null; const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals'; @@ -7440,20 +7438,113 @@ function _renderPendingApprovalForActiveSession() { if (entry) showApprovalCard(entry.pending, entry.pendingCount); } -function _approvalResponseMatches(sid, approvalId) { +function _approvalMirrorOwnerFor(sid, approvalId) { + const entry = _approvalPendingBySession.get(sid); + const pending = entry && entry.pending; + if (!pending || pending.approval_id !== approvalId) return {runId: '', mirrorToken: ''}; + const runId = String(pending.run_id || '').trim(); + const mirrorToken = String(pending._gateway_mirror_token || '').trim(); + return runId && mirrorToken ? {runId, mirrorToken} : {runId: '', mirrorToken: ''}; +} + +function _approvalOwnerForPending(sid, pending) { + if (!pending) return null; + const approvalId = pending.approval_id || null; + if (!sid || !approvalId) return null; + const runId = String(pending.run_id || '').trim(); + const mirrorToken = String(pending._gateway_mirror_token || '').trim(); + return { + sid, + approvalId, + runId: runId && mirrorToken ? runId : '', + mirrorToken: runId && mirrorToken ? mirrorToken : '', + }; +} + +function _approvalOwnerIdentityMatches(left, right) { + return !!( + left && + right && + left.sid === right.sid && + left.approvalId === right.approvalId && + left.runId === right.runId && + left.mirrorToken === right.mirrorToken + ); +} + +function _captureApprovalResponseOwner() { + const card = $("approvalCard"); + const sid = _approvalSessionId; + const approvalId = _approvalCurrentId; + if (!card || !card.classList.contains("visible") || !sid || !approvalId) return null; + if (!S.session || S.session.session_id !== sid) return null; + if ( + !_approvalDisplayedOwner || + _approvalDisplayedOwner.sid !== sid || + _approvalDisplayedOwner.approvalId !== approvalId + ) return null; + return {..._approvalDisplayedOwner, generation: _loadSessionGeneration}; +} + +function _approvalResponseOwnerIsCurrent(owner) { + return !!( + owner && + S.session && + S.session.session_id === owner.sid && + _loadSessionGeneration === owner.generation && + _approvalOwnerIdentityMatches(_approvalDisplayedOwner, owner) + ); +} + +function _approvalResponseMatches( + sid, + approvalId, + generation = _loadSessionGeneration, + mirrorOwner = _approvalMirrorOwnerFor(sid, approvalId), +) { return !!( _approvalResponding && _approvalResponding.sid === sid && - (_approvalResponding.approvalId || null) === (approvalId || null) + _approvalResponding.generation === generation && + _approvalResponding.approvalId === approvalId && + _approvalResponding.runId === mirrorOwner.runId && + _approvalResponding.mirrorToken === mirrorOwner.mirrorToken + ); +} + +function _releaseApprovalResponseOwner(owner) { + if (_approvalResponseMatches(owner.sid, owner.approvalId, owner.generation, owner)) { + _approvalResponding = null; + } + const card = $("approvalCard"); + if ( + _approvalOwnerIdentityMatches(_approvalDisplayedOwner, owner) && + (!card || !card.classList.contains("visible")) + ) { + _approvalDisplayedOwner = null; + } +} + +function _approvalClearedOwnerMayRefresh(owner) { + return !!( + _approvalClearedOwner === owner && + S.session && + S.session.session_id === owner.sid && + _loadSessionGeneration === owner.generation && + _approvalSessionId === null && + _approvalCurrentId === null ); } function _setApprovalControlsDisabled(choice, disabled) { - ["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => { + const loadingId = choice === "skipAll" + ? "approvalSkipAll" + : (choice ? "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1) : null); + ["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny","approvalSkipAll"].forEach(id => { const b = $(id); if (!b) return; b.disabled = !!disabled; - if (disabled && choice && b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) { + if (disabled && b.id === loadingId) { b.classList.add("loading"); } else { b.classList.remove("loading"); @@ -7471,16 +7562,25 @@ function showApprovalCard(pending, pendingCount) { const sid = _rememberApprovalPending(pending, pendingCount); if (!_approvalPromptBelongsToActiveSession(sid)) return; if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return; + _approvalClearedOwner = null; const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []); const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : ""); const cmd = pending.command || ""; - const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null, approval_id: pending.approval_id || null}); + const sig = JSON.stringify({ + desc, + cmd, + sid: pending._session_id || (S.session && S.session.session_id) || null, + approval_id: pending.approval_id || null, + run_id: pending.run_id || null, + mirror_token: pending._gateway_mirror_token || null, + }); const card = $("approvalCard"); const sameApproval = card.classList.contains("visible") && _approvalSignature === sig; $("approvalDesc").textContent = desc; $("approvalCmd").textContent = cmd; _approvalSessionId = sid; _approvalCurrentId = pending.approval_id || null; + _approvalDisplayedOwner = _approvalOwnerForPending(sid, pending); _approvalSignature = sig; // Show "1 of N" counter when multiple approvals are queued const counter = $("approvalCounter"); @@ -7503,7 +7603,7 @@ function showApprovalCard(pending, pendingCount) { } const responding = _approvalResponseMatches(sid, _approvalCurrentId); _setApprovalControlsDisabled( - responding ? _approvalResponding.choice : null, + responding ? (_approvalResponding.controlChoice || _approvalResponding.choice) : null, responding, ); _setPromptFlyoutHidden(card, false); @@ -7575,14 +7675,22 @@ function _syncApprovalTranscriptSpace(card, opts) { setTimeout(measure, 420); } -function _restoreFailedApprovalResponse(sid, errMsg) { - _approvalResponding = null; +function _restoreFailedApprovalResponse(owner, errMsg) { + const isCurrent = _approvalResponseOwnerIsCurrent(owner); + _releaseApprovalResponseOwner(owner); + if (!isCurrent) return; _setApprovalControlsDisabled(null, false); - if (_approvalPromptBelongsToActiveSession(sid)) _renderPendingApprovalForActiveSession(); + _renderPendingApprovalForActiveSession(); if (typeof showToast === "function") showToast(errMsg, 5000); if (typeof setStatus === "function") setStatus(errMsg); } +function _applyApprovalYoloProjection(result) { + if (!result || typeof result.yolo_enabled !== "boolean") return; + _yoloEnabled = result.yolo_enabled; + _updateYoloPill(); +} + function toggleApprovalCardCollapsed(forceCollapsed) { const card = $("approvalCard"); if (!card) return; @@ -7592,59 +7700,84 @@ function toggleApprovalCardCollapsed(forceCollapsed) { _syncApprovalTranscriptSpace(card, {immediate: true}); } -async function respondApproval(choice) { - const sid = _approvalSessionId || (S.session && S.session.session_id); - if (!sid) return; - const approvalId = _approvalCurrentId; - if (_approvalResponseMatches(sid, approvalId)) return; +async function respondApproval(choice, options = {}) { + const owner = options.owner || _captureApprovalResponseOwner(); + if (!_approvalResponseOwnerIsCurrent(owner)) return false; + const {sid, approvalId} = owner; + if (_approvalResponseMatches(sid, approvalId, owner.generation, owner)) return false; + _approvalClearedOwner = null; _unmarkApprovalDismissed(sid, approvalId); - _approvalResponding = {sid, approvalId: approvalId || null, choice}; - _setApprovalControlsDisabled(choice, true); + const controlChoice = options.yolo ? "skipAll" : choice; + _approvalResponding = {...owner, choice}; + _approvalResponding.controlChoice = controlChoice; + _setApprovalControlsDisabled(controlChoice, true); try { const result = await api("/api/approval/respond", { method: "POST", - body: JSON.stringify({ session_id: sid, choice, approval_id: approvalId }) + body: JSON.stringify({ + session_id: sid, + choice, + approval_id: approvalId, + ...(owner.runId ? {run_id: owner.runId} : {}), + ...(owner.mirrorToken ? {mirror_token: owner.mirrorToken} : {}), + ...(options.yolo ? {yolo: true} : {}), + }) }); + if (!_approvalResponseOwnerIsCurrent(owner)) { + _releaseApprovalResponseOwner(owner); + return false; + } if (result && result.ok) { - _approvalResponding = null; + _releaseApprovalResponseOwner(owner); + if (options.yolo) _applyApprovalYoloProjection(result); const pendingEntry = _approvalPendingBySession.get(sid); - const samePending = !!(pendingEntry && pendingEntry.pending && (pendingEntry.pending.approval_id || null) === (approvalId || null)); - // `stale_cleared` means the server found nothing pending for this session - // (the approval already resolved or its stream ended while the card was - // up). The orphan card must be cleared unconditionally so it can never - // get stuck — even if the displayed id has since drifted. (#4948 local - // variant: previously surfaced as a stuck "Approval response not - // accepted." toast.) - if (result.stale_cleared || (_approvalSessionId === sid && _approvalCurrentId === approvalId)) { - _approvalSessionId = null; - _approvalCurrentId = null; - hideApprovalCard(true); - } - if (samePending || result.stale_cleared) _clearApprovalPendingForSession(sid); - // Hardening for the narrow stale-clear race: a brand-new approval could - // have been parked server-side after the server's empty-check but before - // we processed this stale response. The unconditional clear above would - // hide that fresh card. Re-query the authoritative server pending state - // (same endpoint the fallback poll uses) so any approval that arrived in - // the window re-surfaces immediately instead of waiting for the next - // SSE/poll tick. Best-effort; poll/SSE remain the backstop. (Opus review - // nit on the #4948 fix.) + const pendingOwner = _approvalMirrorOwnerFor(sid, approvalId); + const samePending = !!( + pendingEntry && + pendingEntry.pending && + pendingEntry.pending.approval_id === approvalId && + pendingOwner.runId === owner.runId && + pendingOwner.mirrorToken === owner.mirrorToken + ); + if (samePending) _clearApprovalPendingForSession(sid); + _approvalSessionId = null; + _approvalCurrentId = null; + _approvalClearedOwner = owner; + hideApprovalCard(true); if (result.stale_cleared) { - api("/api/approval/pending?session_id=" + encodeURIComponent(sid), {timeoutToast: false}) - .then(data => { - if (data && data.pending && _approvalPromptBelongsToActiveSession(sid)) { - showApprovalForSession(sid, data.pending, data.pending_count || 1); - } - }) - .catch(() => {}); + void (async () => { + if (!_approvalClearedOwnerMayRefresh(owner)) return; + try { + const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid), {timeoutToast: false}); + if (!_approvalClearedOwnerMayRefresh(owner)) return; + _approvalClearedOwner = null; + if (data && data.pending) showApprovalForSession(sid, data.pending, data.pending_count || 1); + } catch (_) { + if (_approvalClearedOwner === owner) _approvalClearedOwner = null; + } + })(); } - return; + if (options.yolo) showToast(t(_yoloEnabled ? 'yolo_enabled' : 'yolo_disabled')); + return options.yolo ? result : true; } const errMsg = (result && result.error) || "Approval response not accepted."; - _restoreFailedApprovalResponse(sid, errMsg); + _restoreFailedApprovalResponse(owner, errMsg); + return false; } catch(e) { - const errMsg = (e && e.message) || (t("approval_responding") + " failed"); - _restoreFailedApprovalResponse(sid, errMsg); + let errorPayload = null; + if (e && typeof e.body === 'string') { + try { errorPayload = JSON.parse(e.body); } catch (_) { /* non-JSON HTTP error */ } + } + const errMsg = (errorPayload && (errorPayload.error || errorPayload.message)) + || (e && e.message) + || (t("approval_responding") + " failed"); + if (!_approvalResponseOwnerIsCurrent(owner)) { + _releaseApprovalResponseOwner(owner); + return false; + } + if (options.yolo) _applyApprovalYoloProjection(errorPayload); + _restoreFailedApprovalResponse(owner, errMsg); + return false; } } diff --git a/tests/test_approval_sse.py b/tests/test_approval_sse.py index dfc4dfc6..d052dce0 100644 --- a/tests/test_approval_sse.py +++ b/tests/test_approval_sse.py @@ -113,8 +113,10 @@ class TestSSEStaticAnalysis: cb_start = streaming_src.find("def _approval_notify_cb(approval_data):") cb_end = streaming_src.find("_reg_notify(session_id, _approval_notify_cb)", cb_start) cb_body = streaming_src[cb_start:cb_end] - assert "head, total = _submit_pending_for_polling(session_id, approval_data)" in cb_body, \ - "_approval_notify_cb must mirror approval data into polling state before SSE" + assert "auto_resolved, head, total = _settle_pending_for_polling(" in cb_body, \ + "_approval_notify_cb must settle local admission before SSE" + assert "if auto_resolved and head is None:" in cb_body, \ + "_approval_notify_cb must suppress SSE for an auto-resolved local approval" assert '"pending_count": total' in cb_body, \ "_approval_notify_cb must emit the reconciled pending count" assert "put('approval', approval_data)" in cb_body, \ diff --git a/tests/test_approval_unblock.py b/tests/test_approval_unblock.py index acd99b01..7369baf2 100644 --- a/tests/test_approval_unblock.py +++ b/tests/test_approval_unblock.py @@ -232,8 +232,10 @@ class TestApprovalModuleExports: assert cb_start != -1, "_approval_notify_cb must exist" cb_end = STREAMING_SRC.find("_reg_notify(session_id, _approval_notify_cb)", cb_start) cb_body = STREAMING_SRC[cb_start:cb_end] - assert "head, total = _submit_pending_for_polling(session_id, approval_data)" in cb_body, \ - "approval notify callback must mirror approval data into polling state" + assert "auto_resolved, head, total = _settle_pending_for_polling(" in cb_body, \ + "approval notify callback must settle admission at the YOLO handoff boundary" + assert "if auto_resolved and head is None:" in cb_body, \ + "an auto-resolved local approval must not publish a stale card" assert '"pending_count": total' in cb_body, \ "approval notify callback must publish the reconciled pending count" assert "put('approval', approval_data)" in cb_body, \ diff --git a/tests/test_gateway_yolo_webui_compat.py b/tests/test_gateway_yolo_webui_compat.py new file mode 100644 index 00000000..caa9023a --- /dev/null +++ b/tests/test_gateway_yolo_webui_compat.py @@ -0,0 +1,1909 @@ +"""WebUI-owned YOLO compatibility tests for gateway Runs API chat. + +The current Runs API can answer one approval but cannot toggle session YOLO. +These tests pin WebUI's compatibility behavior without relying on an Agent +branch that accepts extra request fields. +""" + +import json +import pathlib +import shutil +import subprocess +import threading +import urllib.parse +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest + +import api.config as config +import api.gateway_chat as gateway_chat +from api.runner_client import RunnerClientError + +try: + from tools.approval import disable_session_yolo, is_session_yolo_enabled + + APPROVAL_AVAILABLE = True +except ImportError: + APPROVAL_AVAILABLE = False + + +def _js_block(source, start_marker, end_marker): + start = source.index(start_marker) + return source[start:source.index(end_marker, start)] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_approval_card_yolo_resumes_current_prompt_with_one_webui_request(): + messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text() + + def extract(name, end_marker): + start = messages_js.index(f"async function {name}(") + return messages_js[start:messages_js.index(end_marker, start)] + + script = "\n".join([ + "const calls=[];", + "const S={session:{session_id:'browser-session'}};", + "let _approvalSessionId='browser-session';", + "let _approvalCurrentId='approval-1';", + "let _loadSessionGeneration=1;", + "let _approvalResponding=null;", + "let _approvalClearedOwner=null;", + "let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'run-1',mirrorToken:'mirror-1'};", + "let _yoloEnabled=false;", + "const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1',run_id:'run-1',_gateway_mirror_token:'mirror-1'}}]]);", + "const api=async(path,opts={})=>{calls.push([path,JSON.parse(opts.body||'{}')]);return {ok:true,yolo_enabled:true};};", + "const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});", + "const t=k=>k; const showToast=()=>{}; const setStatus=()=>{};", + "const _unmarkApprovalDismissed=()=>{};", + "const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};", + "const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{};", + _js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"), + _js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"), + extract("respondApproval", "\nfunction startApprovalPolling"), + extract("toggleYoloFromApproval", "\n// ── Approval polling"), + "(async()=>{", + " const ok=await toggleYoloFromApproval();", + " if(!ok) throw new Error('action failed');", + " if(calls.length!==1) throw new Error('expected one request '+JSON.stringify(calls));", + " const [path,body]=calls[0];", + " if(path!=='/api/approval/respond') throw new Error('wrong endpoint '+path);", + " if(JSON.stringify(body)!==JSON.stringify({session_id:'browser-session',choice:'once',approval_id:'approval-1',run_id:'run-1',mirror_token:'mirror-1',yolo:true})) throw new Error('wrong body '+JSON.stringify(body));", + " if(!_yoloEnabled) throw new Error('UI state not enabled');", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_approval_card_yolo_marks_skip_all_busy_while_request_is_pending(): + messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text() + + script = "\n".join([ + "const classes=()=>{const values=new Set();return {add:v=>values.add(v),remove:v=>values.delete(v),contains:v=>values.has(v)};};", + "const buttons=Object.fromEntries(['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny','approvalSkipAll'].map(id=>[id,{id,disabled:false,classList:classes()}]));", + "const card={classList:{contains:v=>v==='visible'}};", + "const $=id=>id==='approvalCard'?card:buttons[id];", + "const S={session:{session_id:'browser-session'}};", + "let _loadSessionGeneration=1; let _approvalSessionId='browser-session'; let _approvalCurrentId='approval-1';", + "let _approvalResponding=null; let _approvalClearedOwner=null; let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'',mirrorToken:''}; let _yoloEnabled=false; let finishRequest;", + "const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1'}}]]);", + "const api=async()=>await new Promise(resolve=>{finishRequest=resolve;});", + "const t=k=>k; const showToast=()=>{}; const setStatus=()=>{}; const _updateYoloPill=()=>{};", + "const _unmarkApprovalDismissed=()=>{}; const _clearApprovalPendingForSession=()=>{}; const hideApprovalCard=()=>{};", + _js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction showApprovalForSession"), + _js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"), + _js_block(messages_js, "async function respondApproval(", "\nfunction startApprovalPolling"), + _js_block(messages_js, "async function toggleYoloFromApproval(", "\n// ── Approval polling"), + "(async()=>{", + " const action=toggleYoloFromApproval();", + " if(!buttons.approvalSkipAll.disabled) throw new Error('clicked Skip all stayed enabled');", + " if(!buttons.approvalSkipAll.classList.contains('loading')) throw new Error('clicked Skip all lacks loading state');", + " if(buttons.approvalBtnOnce.classList.contains('loading')) throw new Error('Allow once incorrectly shows loading state');", + " for(const button of Object.values(buttons)){if(!button.disabled) throw new Error(button.id+' stayed enabled');}", + " finishRequest({ok:true,yolo_enabled:true});", + " if(!(await action)) throw new Error('action failed');", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_card_yolo_drains_all_already_parked_local_approvals(monkeypatch): + from api import routes + from tools.approval import _ApprovalEntry + + sid = "webui-card-yolo-drain-local" + approvals = [ + {"approval_id": "approval-local-1", "command": "first"}, + {"approval_id": "approval-local-2", "command": "second"}, + ] + entries = [_ApprovalEntry(dict(approval)) for approval in approvals] + response = {} + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None)) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) + monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False) + + with routes._lock: + routes._pending[sid] = [dict(approval) for approval in approvals] + routes._gateway_queues[sid] = list(entries) + + try: + routes._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-local-1", + "yolo": True, + }, + ) + + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is True + assert [entry.result for entry in entries] == ["once", "once"] + assert all(entry.event.is_set() for entry in entries) + with routes._lock: + assert sid not in routes._pending + assert sid not in routes._gateway_queues + finally: + disable_session_yolo(sid) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_card_yolo_drains_all_run_backed_and_local_approvals(monkeypatch): + from api import route_approvals, routes + from tools.approval import _ApprovalEntry + + sid = "webui-card-yolo-drain-mixed" + run_approvals = [ + { + "approval_id": "approval-run-1", + "run_id": "run-drain-1", + "command": "remote first", + "_gateway_agent_identity_v1": True, + }, + { + "approval_id": "approval-run-2", + "run_id": "run-drain-2", + "command": "remote second", + "_gateway_agent_identity_v1": True, + }, + ] + local_approval = {"approval_id": "approval-local", "command": "local third"} + local_entry = _ApprovalEntry(dict(local_approval)) + response = {} + relays = [] + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + def fake_respond(_self, run_id, approval_id, choice): + relays.append((run_id, approval_id, choice)) + return {"resolved": 1} + + for approval in run_approvals: + route_approvals.submit_gateway_pending_mirror(sid, dict(approval)) + with routes._lock: + routes._gateway_queues[sid] = [local_entry] + routes._pending[sid].append(dict(local_approval)) + first_mirror = route_approvals.gateway_pending_mirror( + sid, + approval_id="approval-run-1", + run_id="run-drain-1", + ) + assert first_mirror is not None + assert first_mirror.get("_gateway_mirror_token") + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + + try: + routes._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": first_mirror["approval_id"], + "run_id": first_mirror["run_id"], + "mirror_token": first_mirror["_gateway_mirror_token"], + "yolo": True, + }, + ) + + assert response["status"] == 200 + assert response["payload"] == { + "ok": True, + "choice": "once", + "relayed": True, + "yolo_enabled": True, + } + assert relays == [ + ("run-drain-1", "approval-run-1", "once"), + ("run-drain-2", "approval-run-2", "once"), + ] + assert local_entry.result == "once" + assert local_entry.event.is_set() + with routes._lock: + assert sid not in routes._pending + assert sid not in routes._gateway_queues + finally: + disable_session_yolo(sid) + for approval in run_approvals: + route_approvals.retire_gateway_pending_mirror(sid, run_id=approval["run_id"]) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +@pytest.mark.parametrize("enable_yolo", [False, True]) +def test_stale_card_run_owner_cannot_rebind_to_current_run(monkeypatch, enable_yolo): + from api import route_approvals, routes + from tools.approval import _ApprovalEntry + + sid = "webui-card-stale-run-owner" + stream_id = "stream-current-owner" + current_run_id = "run-current-owner" + approval_id = "gateway-reused-approval-id" + response = {} + relays = [] + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + def fake_respond(_self, run_id, got_approval_id, choice): + relays.append((run_id, got_approval_id, choice)) + return {"resolved": 1} + + current = { + "command": "current command", + "approval_id": approval_id, + "run_id": current_run_id, + "_gateway_agent_identity_v1": True, + } + current_entry = _ApprovalEntry(current) + with routes._lock: + routes._gateway_queues[sid] = [current_entry] + route_approvals.submit_gateway_pending_mirror(sid, current) + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id)) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway") + gateway_chat._STREAM_RUN_IDS[stream_id] = current_run_id + + try: + body: dict[str, object] = { + "session_id": sid, + "choice": "once", + "approval_id": approval_id, + "run_id": "run-stale-owner", + "mirror_token": "mirror-stale-owner", + } + if enable_yolo: + body["yolo"] = True + routes._handle_approval_respond(object(), body) + + assert response["status"] == 409 + assert response["payload"]["ok"] is False + assert response["payload"]["code"] == "gateway_run_unavailable" + assert relays == [] + assert not current_entry.event.is_set() + assert route_approvals.gateway_pending_mirror( + sid, approval_id=approval_id, run_id=current_run_id + ) is not None + finally: + disable_session_yolo(sid) + gateway_chat._STREAM_RUN_IDS.pop(stream_id, None) + route_approvals.retire_gateway_pending_mirror(sid, run_id=current_run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_approval_card_yolo_uses_authoritative_disabled_response(): + messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text() + + def extract(name, end_marker): + start = messages_js.index(f"async function {name}(") + return messages_js[start:messages_js.index(end_marker, start)] + + script = "\n".join([ + "const toasts=[]; let pillUpdates=0;", + "const S={session:{session_id:'browser-session'}};", + "let _approvalSessionId='browser-session';", + "let _approvalCurrentId='approval-1';", + "let _loadSessionGeneration=1;", + "let _approvalResponding=null;", + "let _approvalClearedOwner=null;", + "let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'',mirrorToken:''};", + "let _yoloEnabled=true;", + "const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1'}}]]);", + "const api=async()=>({ok:true,yolo_enabled:false});", + "const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});", + "const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=()=>{};", + "const _unmarkApprovalDismissed=()=>{};", + "const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};", + "const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{pillUpdates+=1;};", + _js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"), + _js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"), + extract("respondApproval", "\nfunction startApprovalPolling"), + extract("toggleYoloFromApproval", "\n// ── Approval polling"), + "(async()=>{", + " const ok=await toggleYoloFromApproval();", + " if(!ok) throw new Error('approval action failed');", + " if(_yoloEnabled!==false) throw new Error('authoritative disabled state was ignored');", + " if(pillUpdates!==1) throw new Error('pill was not updated exactly once');", + " if(JSON.stringify(toasts)!==JSON.stringify(['yolo_disabled'])) throw new Error('wrong toast '+JSON.stringify(toasts));", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_failed_approval_relay_applies_authoritative_yolo_and_restores_card(): + messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text() + + def extract(start_marker, end_marker): + start = messages_js.index(start_marker) + return messages_js[start:messages_js.index(end_marker, start)] + + script = "\n".join([ + "const toasts=[]; let pillUpdates=0; let cardRenders=0; let statuses=[];", + "const S={session:{session_id:'browser-session'}};", + "let _approvalSessionId='browser-session';", + "let _approvalCurrentId='approval-1';", + "let _loadSessionGeneration=1;", + "let _approvalResponding=null;", + "let _approvalClearedOwner=null;", + "let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'',mirrorToken:''};", + "let _yoloEnabled=false;", + "const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1'}}]]);", + "const api=async()=>{const e=new Error('relay failed');e.status=502;e.body=JSON.stringify({ok:false,error:'relay failed',yolo_enabled:true});throw e;};", + "const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});", + "const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=msg=>statuses.push(msg);", + "const _unmarkApprovalDismissed=()=>{};", + "const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};", + "const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{pillUpdates+=1;};", + "const _approvalPromptBelongsToActiveSession=()=>true;", + "const _renderPendingApprovalForActiveSession=()=>{cardRenders+=1;};", + extract("function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"), + extract("function _restoreFailedApprovalResponse(", "\nfunction toggleApprovalCardCollapsed"), + extract("async function respondApproval(", "\nfunction startApprovalPolling"), + extract("async function toggleYoloFromApproval(", "\n// ── Approval polling"), + "(async()=>{", + " const ok=await toggleYoloFromApproval();", + " if(ok) throw new Error('failed relay reported success');", + " if(_yoloEnabled!==true) throw new Error('authoritative enabled state was ignored');", + " if(pillUpdates!==1) throw new Error('pill was not updated exactly once');", + " if(cardRenders!==1) throw new Error('approval card was not restored');", + " if(JSON.stringify(toasts)!==JSON.stringify(['relay failed'])) throw new Error('wrong toast '+JSON.stringify(toasts));", + " if(JSON.stringify(statuses)!==JSON.stringify(['relay failed'])) throw new Error('wrong status '+JSON.stringify(statuses));", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_yolo_command_resumes_visible_approval_through_same_atomic_action(): + commands_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "commands.js").read_text() + start = commands_js.index("async function cmdYolo(") + body = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)] + script = "\n".join([ + "const calls=[];", + "const S={session:{session_id:'browser-session'}};", + "let _loadSessionGeneration=1;", + "let _approvalSessionId='browser-session';", + "let _approvalCurrentId='approval-1';", + "let _yoloEnabled=false;", + "let atomicCalls=0;", + "const api=async(path,opts={})=>{calls.push([path,opts]);return {yolo_enabled:false};};", + "const toggleYoloFromApproval=async()=>{atomicCalls+=1;return true;};", + "const $=()=>({classList:{contains:()=>true}});", + "const _captureApprovalResponseOwner=()=>({sid:'browser-session',generation:1,approvalId:'approval-1'});", + "const t=k=>k; const showToast=()=>{}; const _updateYoloPill=()=>{}; const hideApprovalCard=()=>{};", + body, + "(async()=>{", + " await cmdYolo();", + " if(atomicCalls!==1) throw new Error('atomic action not used');", + " if(calls.length!==1||!calls[0][0].startsWith('/api/session/yolo?')) throw new Error('unexpected requests '+JSON.stringify(calls));", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_yolo_command_uses_authoritative_post_response_state(): + commands_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "commands.js").read_text() + start = commands_js.index("async function cmdYolo(") + body = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)] + script = "\n".join([ + "const calls=[]; const toasts=[]; let pillUpdates=0; let cardHides=0;", + "const S={session:{session_id:'browser-session'}};", + "let _loadSessionGeneration=1;", + "let _approvalSessionId=null; let _approvalCurrentId=null; let _yoloEnabled=false;", + "const api=async(path,opts={})=>{calls.push(path);return path.includes('?')?{yolo_enabled:false}:{ok:true,yolo_enabled:false};};", + "const toggleYoloFromApproval=async()=>{throw new Error('card path must not run');};", + "const $=()=>({classList:{contains:()=>false}});", + "const t=k=>k; const showToast=msg=>toasts.push(msg);", + "const _updateYoloPill=()=>{pillUpdates+=1;}; const hideApprovalCard=()=>{cardHides+=1;};", + body, + "(async()=>{", + " await cmdYolo();", + " if(_yoloEnabled!==false) throw new Error('authoritative disabled state was ignored');", + " if(pillUpdates!==1) throw new Error('pill was not updated exactly once');", + " if(cardHides!==0) throw new Error('card hidden despite settled disabled state');", + " if(JSON.stringify(toasts)!==JSON.stringify(['yolo_disabled'])) throw new Error('wrong toast '+JSON.stringify(toasts));", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_yolo_command_applies_authoritative_state_from_failed_post(): + commands_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "commands.js").read_text() + start = commands_js.index("async function cmdYolo(") + body = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)] + script = "\n".join([ + "const toasts=[]; let pillUpdates=0; let cardHides=0;", + "const S={session:{session_id:'browser-session'}};", + "let _loadSessionGeneration=1;", + "let _approvalSessionId=null; let _approvalCurrentId=null; let _yoloEnabled=false;", + "const api=async(path)=>{if(path.includes('?'))return {yolo_enabled:false};const e=new Error('relay busy');e.body=JSON.stringify({error:'relay busy',yolo_enabled:true});throw e;};", + "const toggleYoloFromApproval=async()=>{throw new Error('card path must not run');};", + "const $=()=>({classList:{contains:()=>false}});", + "const t=k=>k; const showToast=msg=>toasts.push(msg);", + "const _updateYoloPill=()=>{pillUpdates+=1;}; const hideApprovalCard=()=>{cardHides+=1;};", + body, + "(async()=>{", + " await cmdYolo();", + " if(_yoloEnabled!==true) throw new Error('authoritative enabled error state was ignored');", + " if(pillUpdates!==1) throw new Error('pill was not updated exactly once');", + " if(cardHides!==0) throw new Error('failed request hid the card');", + " if(JSON.stringify(toasts)!==JSON.stringify(['YOLO: relay busy'])) throw new Error('wrong toast '+JSON.stringify(toasts));", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +def _run_with_approval_events(monkeypatch, *, yolo_enabled, auto_error=None): + requests = [] + browser_events = [] + approvals = [] + responses = [] + + class Response: + def __init__(self, *, body=b"", lines=()): + self.body = body + self.lines = list(lines) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, _size=-1): + return self.body + + def __iter__(self): + return iter(self.lines) + + event_lines = [ + b'event: approval.request\n', + b'data: {"event":"approval.request","approval_id":"approval-1","tool":"terminal","command":"one"}\n', + b'event: approval.request\n', + b'data: {"event":"approval.request","approval_id":"approval-2","tool":"terminal","command":"two"}\n', + b'event: run.completed\n', + b'data: {"event":"run.completed","output":"done"}\n', + ] + responses.extend([ + Response(body=b'{"run_id":"run-1"}'), + Response(lines=event_lines), + ]) + + def fake_urlopen(req, timeout=0): + requests.append(req) + return responses.pop(0) + + def fake_auto(base_url, api_key, run_id, approval_id): + approvals.append((base_url, api_key, run_id, approval_id)) + if auto_error: + raise auto_error + + monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(gateway_chat, "update_active_run", lambda *a, **k: None) + monkeypatch.setattr(gateway_chat, "_publish_gateway_run_id", lambda *a, **k: None) + if yolo_enabled is not None: + monkeypatch.setattr(gateway_chat, "_gateway_session_yolo_enabled", lambda _sid: yolo_enabled) + monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setattr("api.route_approvals.submit_gateway_pending_mirror", lambda _sid, data: (data, 1)) + monkeypatch.setattr("api.route_approvals.retire_gateway_pending_mirror", lambda *a, **k: None) + + final, _usage = gateway_chat._run_gateway_runs_api_streaming( + "browser-session", + "hello", + "test-model", + "/tmp", + "stream-1", + "http://gateway.local", + "secret", + [], + {}, + put_gateway_event=lambda name, data: browser_events.append((name, data)), + cancel_event=threading.Event(), + ) + return final, requests, approvals, browser_events + + +def test_enabled_gateway_yolo_auto_approves_every_later_prompt(monkeypatch): + final, requests, approvals, browser_events = _run_with_approval_events( + monkeypatch, yolo_enabled=True + ) + + assert final == "done" + assert approvals == [ + ("http://gateway.local", "secret", "run-1", "approval-1"), + ("http://gateway.local", "secret", "run-1", "approval-2"), + ] + assert not [event for event in browser_events if event[0] == "approval"] + run_body = json.loads(requests[0].data) + assert "yolo" not in run_body + + +def test_disabled_gateway_yolo_surfaces_prompts_without_auto_approval(monkeypatch): + _final, _requests, approvals, browser_events = _run_with_approval_events( + monkeypatch, yolo_enabled=False + ) + + assert approvals == [] + assert [event[1]["approval_id"] for event in browser_events if event[0] == "approval"] == [ + "approval-1", + "approval-2", + ] + + +def test_gateway_yolo_auto_approval_failure_falls_back_to_visible_card(monkeypatch): + _final, _requests, approvals, browser_events = _run_with_approval_events( + monkeypatch, + yolo_enabled=True, + auto_error=RunnerClientError("pristine API rejected approval"), + ) + + assert len(approvals) == 2 + assert [event[1]["approval_id"] for event in browser_events if event[0] == "approval"] == [ + "approval-1", + "approval-2", + ] + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_inflight_yolo_relay_does_not_auto_approve_later_prompt(monkeypatch): + from api import route_approvals + + sid = "browser-session" + disable_session_yolo(sid) + token = route_approvals.begin_session_yolo_transition(sid) + try: + _final, _requests, approvals, browser_events = _run_with_approval_events( + monkeypatch, + yolo_enabled=None, + ) + + assert approvals == [] + assert [event[1]["approval_id"] for event in browser_events if event[0] == "approval"] == [ + "approval-1", + "approval-2", + ] + finally: + route_approvals.finish_session_yolo_transition(sid, token, succeeded=False) + disable_session_yolo(sid) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_overlapping_yolo_relays_publish_only_confirmed_success(): + from api import route_approvals + + sid = "webui-yolo-overlapping-relays" + disable_session_yolo(sid) + first = route_approvals.begin_session_yolo_transition(sid) + second = route_approvals.begin_session_yolo_transition(sid) + try: + assert is_session_yolo_enabled(sid) is False + route_approvals.finish_session_yolo_transition(sid, first, succeeded=True) + assert is_session_yolo_enabled(sid) is True + route_approvals.finish_session_yolo_transition(sid, second, succeeded=False) + assert is_session_yolo_enabled(sid) is True + finally: + route_approvals.finish_session_yolo_transition(sid, first, succeeded=False) + route_approvals.finish_session_yolo_transition(sid, second, succeeded=False) + disable_session_yolo(sid) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +@pytest.mark.parametrize("relay_fails", [False, True]) +def test_card_yolo_uses_plain_runs_approval_and_rolls_back_on_failure(monkeypatch, relay_fails): + from api import route_approvals as route_approvals + from api import routes + + sid = "webui-yolo-route-failure" if relay_fails else "webui-yolo-route-success" + stream_id = "stream-yolo-route" + run_id = "run-yolo-route" + disable_session_yolo(sid) + captured = {} + + def fake_j(_handler, data, status=200, extra_headers=None): + captured["payload"] = data + captured["status"] = status + return data + + calls = [] + states_during_relay = [] + + def fake_respond(_self, got_run_id, approval_id, choice): + calls.append((got_run_id, approval_id, choice)) + states_during_relay.append(bool(is_session_yolo_enabled(sid))) + if relay_fails: + raise RunnerClientError("relay failed") + return {"resolved": 1} + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id)) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway") + gateway_chat._STREAM_RUN_IDS[stream_id] = run_id + + approval = { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": "approval-route", + "run_id": run_id, + "_gateway_agent_identity_v1": True, + } + route_approvals.submit_gateway_pending_mirror(sid, approval) + try: + routes._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-route", + "yolo": True, + }, + ) + assert calls == [(run_id, "approval-route", "once")] + assert states_during_relay == [False] + assert captured["status"] == (502 if relay_fails else 200) + assert captured["payload"]["ok"] is (not relay_fails) + assert is_session_yolo_enabled(sid) is (not relay_fails) + if not relay_fails: + assert captured["payload"]["yolo_enabled"] is True + finally: + disable_session_yolo(sid) + gateway_chat._STREAM_RUN_IDS.pop(stream_id, None) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_failed_relay_does_not_undo_concurrent_explicit_yolo_enable(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-route-concurrent-enable" + stream_id = "stream-yolo-route-concurrent" + run_id = "run-yolo-route-concurrent" + relay_started = threading.Event() + release_relay = threading.Event() + response = {} + disable_session_yolo(sid) + + def fake_respond(_self, _run_id, _approval_id, _choice): + relay_started.set() + assert release_relay.wait(timeout=5) + raise RunnerClientError("relay failed") + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id)) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway") + gateway_chat._STREAM_RUN_IDS[stream_id] = run_id + route_approvals.submit_gateway_pending_mirror(sid, { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": "approval-route-concurrent", + "run_id": run_id, + "_gateway_agent_identity_v1": True, + }) + + worker = threading.Thread(target=routes._handle_approval_respond, args=( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-route-concurrent", + "yolo": True, + }, + )) + try: + worker.start() + assert relay_started.wait(timeout=5) + explicit_enable = getattr( + route_approvals, + "set_session_yolo_enabled", + lambda session_key, _enabled: route_approvals.enable_session_yolo(session_key), + ) + explicit_enable(sid, True) + release_relay.set() + worker.join(timeout=5) + assert not worker.is_alive() + assert response["status"] == 502 + assert response["payload"]["ok"] is False + assert response["payload"]["yolo_enabled"] is True + assert is_session_yolo_enabled(sid) is True + finally: + release_relay.set() + worker.join(timeout=5) + disable_session_yolo(sid) + gateway_chat._STREAM_RUN_IDS.pop(stream_id, None) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_successful_relay_reports_concurrent_explicit_yolo_disable(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-route-concurrent-disable" + stream_id = "stream-yolo-route-concurrent-disable" + run_id = "run-yolo-route-concurrent-disable" + relay_started = threading.Event() + release_relay = threading.Event() + response = {} + disable_session_yolo(sid) + + def fake_respond(_self, _run_id, _approval_id, _choice): + relay_started.set() + assert release_relay.wait(timeout=5) + return {"resolved": 1} + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id)) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway") + gateway_chat._STREAM_RUN_IDS[stream_id] = run_id + route_approvals.submit_gateway_pending_mirror(sid, { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": "approval-route-concurrent-disable", + "run_id": run_id, + "_gateway_agent_identity_v1": True, + }) + + worker = threading.Thread(target=routes._handle_approval_respond, args=( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-route-concurrent-disable", + "yolo": True, + }, + )) + try: + worker.start() + assert relay_started.wait(timeout=5) + route_approvals.set_session_yolo_enabled(sid, False) + release_relay.set() + worker.join(timeout=5) + assert not worker.is_alive() + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is False + assert is_session_yolo_enabled(sid) is False + finally: + release_relay.set() + worker.join(timeout=5) + disable_session_yolo(sid) + gateway_chat._STREAM_RUN_IDS.pop(stream_id, None) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_post_without_local_card_relays_run_backed_approval(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-post-poll-lag" + run_id = "run-yolo-post-poll-lag" + handler = object() + response = {} + calls = [] + disable_session_yolo(sid) + + def fake_respond(_self, got_run_id, approval_id, choice): + calls.append((got_run_id, approval_id, choice)) + return {"resolved": 1} + + def fake_j(got_handler, data, status=200, extra_headers=None): + assert got_handler is handler + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True}) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway") + route_approvals.submit_gateway_pending_mirror(sid, { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": "approval-yolo-post", + "run_id": run_id, + "_gateway_agent_identity_v1": True, + }) + + try: + routes.handle_post(handler, urllib.parse.urlparse("/api/session/yolo")) + + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is True + assert calls == [(run_id, "approval-yolo-post", "once")] + assert is_session_yolo_enabled(sid) is True + assert route_approvals.gateway_pending_mirror(sid, run_id=run_id) is None + finally: + disable_session_yolo(sid) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_post_preserves_mirror_while_owned_relay_fails(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-post-owned-failure" + run_id = "run-yolo-post-owned-failure" + approval_id = "approval-yolo-post-owned-failure" + card_handler = object() + toggle_handler = object() + relay_started = threading.Event() + release_relay = threading.Event() + toggle_entered = threading.Event() + responses = {} + relay_attempts = [] + disable_session_yolo(sid) + + def fake_respond(_self, _run_id, _approval_id, _choice): + relay_attempts.append((_run_id, _approval_id, _choice)) + relay_started.set() + assert release_relay.wait(timeout=5) + raise RunnerClientError("relay failed") + + def fake_read_body(_handler): + toggle_entered.set() + return {"session_id": sid, "enabled": True} + + def fake_j(handler, data, status=200, extra_headers=None): + responses[handler] = {"payload": data, "status": status} + return data + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "read_body", fake_read_body) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + monkeypatch.setattr( + routes, + "get_session", + lambda _sid, **_kwargs: SimpleNamespace(active_stream_id=None, profile=None), + ) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway") + route_approvals.submit_gateway_pending_mirror(sid, { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": approval_id, + "run_id": run_id, + "_gateway_agent_identity_v1": True, + }) + worker = threading.Thread(target=routes._handle_approval_respond, args=( + card_handler, + {"session_id": sid, "choice": "once", "approval_id": approval_id, "yolo": True}, + )) + toggle_worker = threading.Thread( + target=lambda: routes.handle_post( + toggle_handler, urllib.parse.urlparse("/api/session/yolo") + ) + ) + + try: + worker.start() + assert relay_started.wait(timeout=5) + toggle_worker.start() + assert toggle_entered.wait(timeout=5) + assert toggle_worker.is_alive() + assert toggle_handler not in responses + + release_relay.set() + worker.join(timeout=5) + toggle_worker.join(timeout=5) + assert not worker.is_alive() + assert not toggle_worker.is_alive() + assert relay_attempts == [ + (run_id, approval_id, "once"), + (run_id, approval_id, "once"), + ] + assert responses[card_handler]["status"] == 502 + assert responses[toggle_handler]["status"] == 502 + assert responses[card_handler]["payload"]["yolo_enabled"] is False + assert responses[toggle_handler]["payload"]["yolo_enabled"] is False + assert is_session_yolo_enabled(sid) is False + assert route_approvals.gateway_pending_mirror( + sid, approval_id=approval_id, run_id=run_id + ) is not None + finally: + release_relay.set() + worker.join(timeout=5) + toggle_worker.join(timeout=5) + disable_session_yolo(sid) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_post_keeps_transition_unconfirmed_until_second_mirror_check(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-post-transition-window" + handler = object() + observed_transition_states = [] + observed_gateway_states = [] + response = {} + mirror = { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": "approval-transition-window", + "run_id": "run-transition-window", + "_gateway_mirror": True, + "_gateway_agent_identity_v1": True, + } + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + def fake_mirrors(_sid): + with route_approvals._yolo_transition_lock: + observed_transition_states.append(bool(route_approvals._yolo_transitions.get(sid))) + return [dict(mirror)] + + def fake_relay(_sid, _mirror, _choice, *, enable_yolo): + assert enable_yolo is False + with route_approvals._yolo_transition_lock: + observed_transition_states.append(bool(route_approvals._yolo_transitions.get(sid))) + observed_gateway_states.append(gateway_chat._gateway_session_yolo_enabled(sid)) + return { + "ok": True, + "choice": "once", + "relayed": True, + "yolo_enabled": False, + }, 200 + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True}) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + monkeypatch.setattr(routes, "gateway_pending_mirrors", fake_mirrors) + monkeypatch.setattr(routes, "_relay_gateway_run_approval", fake_relay) + + try: + routes.handle_post(handler, urllib.parse.urlparse("/api/session/yolo")) + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is True + assert observed_transition_states == [True, True] + assert observed_gateway_states == [False] + assert is_session_yolo_enabled(sid) is True + finally: + disable_session_yolo(sid) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_no_run_approval_success_reports_authoritative_yolo_state(monkeypatch): + from api import route_approvals, routes + + sid = "webui-no-run-authoritative-success" + response = {} + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + original_enable = route_approvals.enable_session_yolo + + def racing_enable(session_key): + original_enable(session_key) + disable_session_yolo(session_key) + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None)) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + monkeypatch.setattr(routes, "resolve_gateway_pending_local_no_run_mirror", lambda *_a: (True, 1, None, 0)) + monkeypatch.setattr(route_approvals, "enable_session_yolo", racing_enable) + + try: + routes._handle_approval_respond( + object(), + {"session_id": sid, "choice": "once", "approval_id": "approval-no-run", "yolo": True}, + ) + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is False + finally: + disable_session_yolo(sid) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_local_approval_success_reports_authoritative_yolo_state(monkeypatch): + from api import route_approvals, routes + + sid = "webui-local-authoritative-success" + response = {} + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + original_enable = route_approvals.enable_session_yolo + + def racing_enable(session_key): + original_enable(session_key) + disable_session_yolo(session_key) + + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None)) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) + monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False) + monkeypatch.setattr(routes, "_resolve_approval_legacy", lambda *_a: True) + monkeypatch.setattr(route_approvals, "enable_session_yolo", racing_enable) + + try: + routes._handle_approval_respond( + object(), + {"session_id": sid, "choice": "once", "approval_id": "approval-local", "yolo": True}, + ) + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is False + finally: + disable_session_yolo(sid) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_post_serializes_post_snapshot_gateway_approval(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-post-snapshot-handoff" + handler = object() + response = {} + worker_results = [] + workers = [] + auto_approved = [] + approval = { + "command": "touch /tmp/webui-yolo-test", + "description": "test", + "approval_id": "approval-post-snapshot", + "run_id": "run-post-snapshot", + "_gateway_mirror": True, + "_gateway_agent_identity_v1": True, + } + + class NotifyingLock: + def __init__(self): + self._lock = threading.Lock() + self.contended = threading.Event() + + def __enter__(self): + if not self._lock.acquire(blocking=False): + self.contended.set() + self._lock.acquire() + return self + + def __exit__(self, _exc_type, _exc, _tb): + self._lock.release() + + handoff_lock = NotifyingLock() + original_finish = routes.finish_session_yolo_transition + disable_session_yolo(sid) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + def racing_finish(session_key, token, *, succeeded): + worker = threading.Thread( + target=lambda: worker_results.append( + gateway_chat._settle_gateway_run_approval(sid, dict(approval), "http://gateway", "") + ) + ) + worker.start() + workers.append(worker) + assert handoff_lock.contended.wait(timeout=1) + return original_finish(session_key, token, succeeded=succeeded) + + monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", lambda *_a: auto_approved.append(True)) + monkeypatch.setattr(routes, "finish_session_yolo_transition", racing_finish) + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True}) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + monkeypatch.setattr(routes, "gateway_pending_mirror", lambda _sid: None) + monkeypatch.setattr(routes, "reconcile_gateway_pending_mirror_locked", lambda _sid: (None, 0, False)) + monkeypatch.setattr(routes, "resolve_gateway_approval", lambda *_a, **_k: 0) + + try: + routes.handle_post(handler, urllib.parse.urlparse("/api/session/yolo")) + for worker in workers: + worker.join(timeout=1) + assert not worker.is_alive() + assert response["status"] == 200 + assert response["payload"] == {"ok": True, "yolo_enabled": True} + assert worker_results == [(True, None, 0)] + assert auto_approved == [True] + assert route_approvals.gateway_pending_mirror(sid) is None + finally: + disable_session_yolo(sid) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_drain_serializes_post_drain_local_approval(monkeypatch): + from api import route_approvals, routes + from tools.approval import _ApprovalEntry + + sid = "webui-yolo-post-drain-local-handoff" + response = {} + worker_results = [] + worker_errors = [] + workers = [] + initial = _ApprovalEntry({"approval_id": "approval-initial", "command": "initial"}) + late = _ApprovalEntry({"command": "late"}) + + class NotifyingLock: + def __init__(self): + self._lock = threading.Lock() + self.contended = threading.Event() + + def __enter__(self): + if not self._lock.acquire(blocking=False): + self.contended.set() + self._lock.acquire() + return self + + def __exit__(self, _exc_type, _exc, _tb): + self._lock.release() + + handoff_lock = NotifyingLock() + original_drain = route_approvals.resolve_gateway_pending_local_all + disable_session_yolo(sid) + with routes._lock: + routes._gateway_queues[sid] = [initial] + routes._pending[sid] = [dict(initial.data)] + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + def notify_late_local_approval(): + try: + worker_results.append( + route_approvals.settle_gateway_pending_local_notification(sid, dict(late.data)) + ) + except BaseException as exc: + worker_errors.append(exc) + + def paused_after_drain(session_key, choice, reason=None): + result = original_drain(session_key, choice, reason) + with routes._lock: + routes._gateway_queues.setdefault(sid, []).append(late) + worker = threading.Thread(target=notify_late_local_approval) + worker.start() + workers.append(worker) + assert handoff_lock.contended.wait(timeout=1) + assert not late.event.is_set() + return result + + monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(routes, "resolve_gateway_pending_local_all", paused_after_drain) + monkeypatch.setattr(routes, "j", fake_j) + + try: + routes._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-initial", + "yolo": True, + }, + ) + for worker in workers: + worker.join(timeout=1) + assert not worker.is_alive() + + assert worker_errors == [] + assert response["status"] == 200 + assert response["payload"]["ok"] is True + assert response["payload"]["yolo_enabled"] is True + assert initial.event.is_set() + assert initial.result == "once" + assert late.event.is_set() + assert late.result == "once" + assert worker_results == [(True, None, 0)] + with routes._lock: + assert sid not in routes._gateway_queues + assert sid not in routes._pending + finally: + for worker in workers: + worker.join(timeout=1) + disable_session_yolo(sid) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_disable_linearizes_after_selected_gateway_autoapproval(monkeypatch): + from api import routes + + sid = "webui-yolo-disable-autoapprove-race" + approval_started = threading.Event() + release_approval = threading.Event() + disable_entered = threading.Event() + disable_handoff_attempted = threading.Event() + disable_handoff_acquired = threading.Event() + events = [] + worker_results = [] + response = {} + approval = { + "approval_id": "approval-disable-race", + "run_id": "run-disable-race", + "_gateway_agent_identity_v1": True, + } + routes.set_session_yolo_enabled(sid, True) + original_handoff = routes.gateway_yolo_handoff + + @contextmanager + def observed_disable_handoff(session_key): + disable_handoff_attempted.set() + with original_handoff(session_key): + disable_handoff_acquired.set() + yield + + def fake_auto_approve(*_args): + events.append("approval-started") + approval_started.set() + assert release_approval.wait(timeout=1) + events.append("approval-finished") + + def fake_read_body(_handler): + disable_entered.set() + return {"session_id": sid, "enabled": False} + + def fake_j(_handler, data, status=200, extra_headers=None): + events.append("disable-response") + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto_approve) + monkeypatch.setattr(routes, "read_body", fake_read_body) + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "gateway_yolo_handoff", observed_disable_handoff) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + + approval_worker = threading.Thread( + target=lambda: worker_results.append( + gateway_chat._settle_gateway_run_approval(sid, dict(approval), "http://gateway", "") + ) + ) + disable_worker = threading.Thread( + target=lambda: routes.handle_post(object(), urllib.parse.urlparse("/api/session/yolo")) + ) + + try: + approval_worker.start() + assert approval_started.wait(timeout=1) + disable_worker.start() + assert disable_entered.wait(timeout=1) + assert disable_handoff_attempted.wait(timeout=1) + assert not disable_handoff_acquired.is_set() + assert "disable-response" not in events + release_approval.set() + approval_worker.join(timeout=1) + disable_worker.join(timeout=1) + assert not approval_worker.is_alive() + assert not disable_worker.is_alive() + assert disable_handoff_acquired.is_set() + assert worker_results == [(True, None, 0)] + assert events == ["approval-started", "approval-finished", "disable-response"] + assert response["payload"] == {"ok": True, "yolo_enabled": False} + assert is_session_yolo_enabled(sid) is False + finally: + release_approval.set() + disable_session_yolo(sid) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_disable_that_wins_handoff_precedes_enable_drain(monkeypatch): + from api import routes + from tools.approval import _ApprovalEntry + + sid = "webui-yolo-disable-before-enable-handoff" + handoff_lock = threading.Lock() + enable_handoff_attempted = threading.Event() + allow_enable_handoff = threading.Event() + responses = {} + worker_errors = [] + entry = _ApprovalEntry({"approval_id": "approval-after-disable", "command": "late enable"}) + + @contextmanager + def ordered_handoff(_session_key): + role = threading.current_thread().name + if role == "yolo-enable": + enable_handoff_attempted.set() + assert allow_enable_handoff.wait(timeout=1) + with handoff_lock: + yield + + def fake_j(_handler, data, status=200, extra_headers=None): + responses[threading.current_thread().name] = {"payload": data, "status": status} + return data + + def run_enable(): + try: + routes._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-after-disable", + "yolo": True, + }, + ) + except BaseException as exc: + worker_errors.append(exc) + + def run_disable(): + try: + routes.handle_post(object(), urllib.parse.urlparse("/api/session/yolo")) + except BaseException as exc: + worker_errors.append(exc) + + disable_session_yolo(sid) + with routes._lock: + routes._gateway_queues[sid] = [entry] + routes._pending[sid] = [dict(entry.data)] + + monkeypatch.setattr(routes, "gateway_yolo_handoff", ordered_handoff) + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": False}) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + + enable_worker = threading.Thread(target=run_enable, name="yolo-enable") + disable_worker = threading.Thread(target=run_disable, name="yolo-disable") + try: + enable_worker.start() + assert enable_handoff_attempted.wait(timeout=1) + disable_worker.start() + disable_worker.join(timeout=1) + assert not disable_worker.is_alive() + assert responses["yolo-disable"]["payload"] == {"ok": True, "yolo_enabled": False} + assert not entry.event.is_set() + + allow_enable_handoff.set() + enable_worker.join(timeout=1) + assert not enable_worker.is_alive() + assert worker_errors == [] + assert responses["yolo-enable"]["status"] == 200 + assert responses["yolo-enable"]["payload"]["ok"] is True + assert responses["yolo-enable"]["payload"]["yolo_enabled"] is True + assert entry.event.is_set() + assert entry.result == "once" + assert is_session_yolo_enabled(sid) is True + finally: + allow_enable_handoff.set() + enable_worker.join(timeout=1) + disable_worker.join(timeout=1) + disable_session_yolo(sid) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +def test_yolo_post_first_relay_serializes_next_gateway_approval(monkeypatch): + from api import route_approvals, routes + + sid = "webui-yolo-first-relay-next-approval" + run_id = "run-first-relay-next-approval" + response = {} + stream_results = [] + stream_workers = [] + auto_approved = [] + approval_a = { + "command": "touch /tmp/webui-yolo-a", + "description": "first", + "approval_id": "approval-first", + "run_id": run_id, + "_gateway_mirror": True, + "_gateway_agent_identity_v1": True, + } + approval_b = { + "command": "touch /tmp/webui-yolo-b", + "description": "second", + "approval_id": "approval-second", + "run_id": run_id, + "_gateway_mirror": True, + "_gateway_agent_identity_v1": True, + } + + class NotifyingLock: + def __init__(self): + self._lock = threading.Lock() + self.contended = threading.Event() + + def __enter__(self): + if not self._lock.acquire(blocking=False): + self.contended.set() + self._lock.acquire() + return self + + def __exit__(self, _exc_type, _exc, _tb): + self._lock.release() + + handoff_lock = NotifyingLock() + disable_session_yolo(sid) + route_approvals.submit_gateway_pending_mirror(sid, dict(approval_a)) + + def next_stream_approval(): + stream_results.append( + gateway_chat._settle_gateway_run_approval(sid, dict(approval_b), "http://gateway", "") + ) + + def fake_respond(_self, got_run_id, approval_id, choice): + assert (got_run_id, approval_id, choice) == (run_id, "approval-first", "once") + worker = threading.Thread(target=next_stream_approval) + worker.start() + stream_workers.append(worker) + assert handoff_lock.contended.wait(timeout=1) + return {"resolved": 1} + + def fake_auto_approve(_base_url, _api_key, got_run_id, approval_id): + auto_approved.append((got_run_id, approval_id)) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto_approve) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True}) + monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) + monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False) + + try: + routes.handle_post(object(), urllib.parse.urlparse("/api/session/yolo")) + for worker in stream_workers: + worker.join(timeout=1) + assert not worker.is_alive() + assert response["status"] == 200 + assert response["payload"] == { + "ok": True, + "choice": "once", + "relayed": True, + "yolo_enabled": True, + } + assert stream_results == [(True, None, 0)] + assert auto_approved == [(run_id, "approval-second")] + assert route_approvals.gateway_pending_mirror(sid) is None + finally: + disable_session_yolo(sid) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable") +@pytest.mark.parametrize("relay_fails", [False, True]) +def test_card_yolo_first_relay_serializes_next_gateway_approval(monkeypatch, relay_fails): + from api import route_approvals, routes + + sid = ( + "webui-card-yolo-first-relay-failure" + if relay_fails + else "webui-card-yolo-first-relay-success" + ) + run_id = "run-card-yolo-first-relay" + response = {} + stream_results = [] + stream_workers = [] + auto_approved = [] + approval_a = { + "command": "touch /tmp/webui-yolo-a", + "description": "first", + "approval_id": "approval-card-first", + "run_id": run_id, + "_gateway_mirror": True, + "_gateway_agent_identity_v1": True, + } + approval_b = { + "command": "touch /tmp/webui-yolo-b", + "description": "second", + "approval_id": "approval-card-second", + "run_id": run_id, + "_gateway_mirror": True, + "_gateway_agent_identity_v1": True, + } + + class NotifyingLock: + def __init__(self): + self._lock = threading.Lock() + self.contended = threading.Event() + + def __enter__(self): + if not self._lock.acquire(blocking=False): + self.contended.set() + self._lock.acquire() + return self + + def __exit__(self, _exc_type, _exc, _tb): + self._lock.release() + + handoff_lock = NotifyingLock() + disable_session_yolo(sid) + route_approvals.submit_gateway_pending_mirror(sid, dict(approval_a)) + + def next_stream_approval(): + stream_results.append( + gateway_chat._settle_gateway_run_approval( + sid, dict(approval_b), "http://gateway", "" + ) + ) + + def fake_respond(_self, got_run_id, approval_id, choice): + assert (got_run_id, approval_id, choice) == ( + run_id, + "approval-card-first", + "once", + ) + worker = threading.Thread(target=next_stream_approval) + worker.start() + stream_workers.append(worker) + handoff_lock.contended.wait(timeout=1) + if relay_fails: + raise RunnerClientError("relay failed") + return {"resolved": 1} + + def fake_auto_approve(_base_url, _api_key, got_run_id, approval_id): + auto_approved.append((got_run_id, approval_id)) + + def fake_j(_handler, data, status=200, extra_headers=None): + response.update(payload=data, status=status) + return data + + monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock) + monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None)) + monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto_approve) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True) + monkeypatch.setattr(routes, "j", fake_j) + + try: + routes._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "once", + "approval_id": "approval-card-first", + "yolo": True, + }, + ) + for worker in stream_workers: + worker.join(timeout=1) + assert not worker.is_alive() + + assert handoff_lock.contended.is_set() + assert response["status"] == (502 if relay_fails else 200) + assert response["payload"]["ok"] is (not relay_fails) + assert response["payload"]["yolo_enabled"] is (not relay_fails) + assert stream_results and stream_results[0][0] is (not relay_fails) + if relay_fails: + assert auto_approved == [] + assert route_approvals.gateway_pending_mirror( + sid, approval_id=approval_b["approval_id"], run_id=run_id + ) is not None + else: + assert auto_approved == [(run_id, approval_b["approval_id"])] + assert route_approvals.gateway_pending_mirror( + sid, approval_id=approval_b["approval_id"], run_id=run_id + ) is None + finally: + for worker in stream_workers: + worker.join(timeout=1) + disable_session_yolo(sid) + route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id) + with routes._lock: + routes._pending.pop(sid, None) + routes._gateway_queues.pop(sid, None) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_card_yolo_response_does_not_overwrite_switched_session_state(): + messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text() + + def extract(name, end_marker): + start = messages_js.index(f"async function {name}(") + return messages_js[start:messages_js.index(end_marker, start)] + + respond_approval = extract("respondApproval", "\nfunction startApprovalPolling") + toggle_yolo = extract("toggleYoloFromApproval", "\n// ── Approval polling") + + def run(api_source, expected_ok): + script = "\n".join([ + "const toasts=[]; let pillUpdates=0;", + "const S={session:{session_id:'old-session'}};", + "let _loadSessionGeneration=1;", + "let _approvalSessionId='old-session';", + "let _approvalCurrentId='approval-1';", + "let _approvalResponding=null;", + "let _approvalClearedOwner=null;", + "let _approvalDisplayedOwner={sid:'old-session',approvalId:'approval-1',runId:'',mirrorToken:''};", + "let _yoloEnabled=false;", + "const _approvalPendingBySession=new Map([['old-session',{pending:{approval_id:'approval-1'}}]]);", + f"const api={api_source};", + "const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});", + "const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=()=>{};", + "const _unmarkApprovalDismissed=()=>{};", + "const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};", + "const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{pillUpdates+=1;};", + "const _restoreFailedApprovalResponse=()=>{};", + _js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"), + _js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"), + respond_approval, + toggle_yolo, + "(async()=>{", + " const ok=await toggleYoloFromApproval();", + f" if(ok!=={str(expected_ok).lower()}) throw new Error('unexpected result '+ok);", + " if(_yoloEnabled!==false) throw new Error('stale response changed new session state');", + " if(pillUpdates!==0) throw new Error('stale response updated the new session pill');", + " if(toasts.length!==0) throw new Error('stale response emitted a toast');", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + run( + "async()=>{S.session.session_id='new-session';return {ok:true,yolo_enabled:true};}", + False, + ) + run( + "async()=>{S.session.session_id='new-session';const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}", + False, + ) + run( + "async()=>{_loadSessionGeneration=2;return {ok:true,yolo_enabled:true};}", + False, + ) + run( + "async()=>{_loadSessionGeneration=2;const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}", + False, + ) + + +def _run_card_yolo_owner_scenario(scenario): + messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text() + + def extract(start_marker, end_marker): + start = messages_js.index(start_marker) + return messages_js[start:messages_js.index(end_marker, start)] + + owner_sid = "card-session" + active_sid = "other-session" if scenario.startswith("inactive-") else owner_sid + approval_id = "Approval-Case-ID" if scenario.startswith("case-distinct-") else "approval-1" + script = "\n".join([ + "const calls=[]; const toasts=[]; const statuses=[]; const controlWrites=[];", + "let pillUpdates=0; let cardHideMutations=0; let pendingClears=0; let cardRenders=0;", + f"const scenario={json.dumps(scenario)};", + f"const ownerSid={json.dumps(owner_sid)}; const approvalId={json.dumps(approval_id)};", + f"const S={{session:{{session_id:{json.dumps(active_sid)}}}}};", + "let _loadSessionGeneration=1;", + "let _approvalSessionId=ownerSid; let _approvalCurrentId=approvalId;", + "let _approvalResponding=null; let _approvalClearedOwner=null; let _yoloEnabled=false;", + "let _approvalHideTimer=null; let _approvalVisibleSince=0; let _approvalSignature='owner-card';", + "const initialPending=(scenario==='mirror-distinct-success'||scenario==='poll-cleared-success')?{approval_id:approvalId,run_id:'run-owner-1',_gateway_mirror_token:'mirror-owner-1'}:{approval_id:approvalId};", + "let _approvalDisplayedOwner={sid:ownerSid,approvalId,runId:initialPending.run_id||'',mirrorToken:initialPending._gateway_mirror_token||''};", + "const _approvalPendingBySession=new Map([[ownerSid,{pending:initialPending}]]);", + "const classList={", + " values:new Set(['visible']),", + " contains(v){return this.values.has(v);},", + " add(v){this.values.add(v);},", + " remove(...values){for(const v of values){if(v==='visible'&&this.values.has(v))cardHideMutations+=1;this.values.delete(v);}},", + " toggle(v,on){if(on===undefined)on=!this.values.has(v);if(on)this.values.add(v);else this.values.delete(v);return on;},", + "};", + "const card={classList,hidden:false,setAttribute(){},removeAttribute(){}};", + "const textNode={textContent:''};", + "const makeButton=()=>{let value=false;return{classList:{add(){},remove(){}},get disabled(){return value;},set disabled(next){value=!!next;controlWrites.push(value);}};};", + "const elements={approvalCard:card,approvalCmd:{...textNode},approvalDesc:{...textNode},approvalCounter:{...textNode,style:{}},approvalBtnOnce:makeButton(),approvalBtnSession:makeButton(),approvalBtnAlways:makeButton(),approvalBtnDeny:makeButton()};", + "const $=id=>elements[id]||null;", + "const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=msg=>statuses.push(msg);", + "const _setPromptFlyoutHidden=()=>{};", + "const _updateYoloPill=()=>{pillUpdates+=1;}; const syncTopbar=()=>{};", + "const _unmarkApprovalDismissed=()=>{};", + "const _clearApprovalPendingForSession=sid=>{pendingClears+=1;_approvalPendingBySession.delete(sid);};", + "const _approvalPromptBelongsToActiveSession=sid=>!!(sid&&S.session&&S.session.session_id===sid);", + "const _renderPendingApprovalForActiveSession=()=>{cardRenders+=1;};", + "const api=async(path,opts={})=>{", + " calls.push({path,body:opts.body?JSON.parse(opts.body):null});", + " if(path.startsWith('/api/approval/pending'))return {pending:null};", + " if(scenario.startsWith('same-generation-')){_loadSessionGeneration=2;_approvalSessionId=ownerSid;_approvalCurrentId=approvalId;classList.add('visible');}", + " if(scenario==='poll-cleared-success'){_approvalPendingBySession.delete(ownerSid);}", + " if(scenario==='mirror-distinct-success'){_approvalPendingBySession.set(ownerSid,{pending:{approval_id:approvalId,run_id:'run-owner-2',_gateway_mirror_token:'mirror-owner-2'}});_approvalDisplayedOwner={sid:ownerSid,approvalId,runId:'run-owner-2',mirrorToken:'mirror-owner-2'};classList.add('visible');}", + " if(scenario==='case-distinct-id'){_approvalCurrentId=approvalId.toLowerCase();_approvalDisplayedOwner={sid:ownerSid,approvalId:_approvalCurrentId,runId:'',mirrorToken:''};classList.add('visible');}", + " if(scenario==='case-distinct-pending'){_approvalPendingBySession.set(ownerSid,{pending:{approval_id:approvalId.toLowerCase()}});}", + " if(scenario.endsWith('-error')){const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}", + " return {ok:true,yolo_enabled:true,stale_cleared:scenario==='same-generation-stale-cleared'||scenario==='case-distinct-pending'};", + "};", + extract("function _clearApprovalHideTimer(", "\n// Track session_id of the active approval"), + extract("function _approvalMirrorOwnerFor(", "\nfunction startApprovalPolling"), + extract("async function toggleYoloFromApproval(", "\n// ── Approval polling"), + "(async()=>{", + " const ok=await toggleYoloFromApproval();", + " await new Promise(resolve=>setTimeout(resolve,0));", + " if(scenario==='poll-cleared-success'){", + " if(!ok)throw new Error('retired polling projection discarded valid success');", + " if(calls.length!==1)throw new Error('valid response made extra requests '+JSON.stringify(calls));", + " if(_approvalPendingBySession.has(ownerSid))throw new Error('polling projection was recreated');", + " if(_approvalResponding!==null)throw new Error('response owner was not released');", + " if(cardHideMutations!==1||pendingClears!==0||cardRenders!==0)throw new Error('wrong retired-owner cleanup '+JSON.stringify({cardHideMutations,pendingClears,cardRenders}));", + " if(_yoloEnabled!==true||pillUpdates!==1)throw new Error('valid success did not project YOLO');", + " if(JSON.stringify(toasts)!==JSON.stringify(['yolo_enabled'])||statuses.length)throw new Error('wrong valid-success feedback '+JSON.stringify({toasts,statuses}));", + " return;", + " }", + " if(scenario==='case-distinct-pending'){", + " if(!ok)throw new Error('owned response reported failure');", + " if(calls.length!==2)throw new Error('expected response plus requery '+JSON.stringify(calls));", + " if(calls[0].body.approval_id!==approvalId)throw new Error('approval ID changed '+JSON.stringify(calls[0].body));", + " const successor=_approvalPendingBySession.get(ownerSid);", + " if(!successor||successor.pending.approval_id!==approvalId.toLowerCase())throw new Error('case-distinct pending successor was cleared');", + " if(JSON.stringify(controlWrites)!==JSON.stringify([true,true,true,true]))throw new Error('response changed successor controls '+JSON.stringify(controlWrites));", + " if(_approvalResponding!==null)throw new Error('response owner was not released');", + " if(cardHideMutations!==1||pendingClears!==0||cardRenders!==0)throw new Error('wrong owned cleanup '+JSON.stringify({cardHideMutations,pendingClears,cardRenders}));", + " if(_yoloEnabled!==true||pillUpdates!==1)throw new Error('owned response did not project YOLO');", + " if(JSON.stringify(toasts)!==JSON.stringify(['yolo_enabled'])||statuses.length)throw new Error('wrong owned feedback '+JSON.stringify({toasts,statuses}));", + " return;", + " }", + " if(scenario.startsWith('inactive-')){", + " if(ok!==false)throw new Error('inactive card action reported success');", + " if(calls.length!==0)throw new Error('inactive card posted '+JSON.stringify(calls));", + " if(controlWrites.length!==0)throw new Error('inactive card changed controls '+JSON.stringify(controlWrites));", + " }else{", + " if(calls.length!==1)throw new Error('stale response made extra requests '+JSON.stringify(calls));", + " if(calls[0].body.approval_id!==approvalId)throw new Error('approval ID changed '+JSON.stringify(calls[0].body));", + " if(JSON.stringify(controlWrites)!==JSON.stringify([true,true,true,true]))throw new Error('stale response changed controls '+JSON.stringify(controlWrites));", + " }", + " if(_approvalResponding!==null)throw new Error('response owner was not released');", + " if(cardHideMutations!==0)throw new Error('stale response hid successor card');", + " if(pendingClears!==0)throw new Error('stale response cleared successor pending state');", + " if(cardRenders!==0)throw new Error('stale response rendered a card');", + " if(_yoloEnabled!==false||pillUpdates!==0)throw new Error('stale response changed YOLO projection');", + " if(toasts.length||statuses.length)throw new Error('stale response emitted feedback '+JSON.stringify({toasts,statuses}));", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + node = shutil.which("node") + assert node is not None + result = subprocess.run([node, "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +@pytest.mark.parametrize("scenario", ["inactive-success", "inactive-error"]) +def test_card_yolo_rejects_visible_approval_owned_by_inactive_session(scenario): + _run_card_yolo_owner_scenario(scenario) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +@pytest.mark.parametrize("scenario", [ + "same-generation-success", + "same-generation-stale-cleared", + "same-generation-error", +]) +def test_card_yolo_ignores_response_from_prior_same_session_generation(scenario): + _run_card_yolo_owner_scenario(scenario) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_card_yolo_keeps_case_distinct_successor_approval_untouched(): + _run_card_yolo_owner_scenario("case-distinct-id") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_stale_clear_preserves_case_distinct_pending_successor(): + _run_card_yolo_owner_scenario("case-distinct-pending") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_card_yolo_keeps_same_id_different_mirror_successor_untouched(): + _run_card_yolo_owner_scenario("mirror-distinct-success") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_card_yolo_accepts_success_after_polling_retires_projection(): + _run_card_yolo_owner_scenario("poll-cleared-success") diff --git a/tests/test_issue467_yolo_mode_toggle.py b/tests/test_issue467_yolo_mode_toggle.py index 623e1a58..48658e91 100644 --- a/tests/test_issue467_yolo_mode_toggle.py +++ b/tests/test_issue467_yolo_mode_toggle.py @@ -13,6 +13,8 @@ import os import re import json import pathlib +import shutil +import subprocess import pytest from tests.conftest import requires_agent_modules @@ -265,3 +267,44 @@ class TestYoloI18n: for key in self.REQUIRED_KEYS: assert key in block, f"Key '{key}' missing in locale '{locale}'" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not available") +def test_yolo_command_ignores_stale_same_session_generation(): + commands_js = _read_static_file("commands.js") + start = commands_js.index("async function cmdYolo(") + cmd_yolo = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)] + + def run(api_source, expected_calls): + script = "\n".join([ + "const calls=[]; const toasts=[]; let pillUpdates=0; let cardHides=0;", + "const S={session:{session_id:'old-session'}};", + "let _loadSessionGeneration=1;", + "let _yoloEnabled=false; let _approvalSessionId=null; let _approvalCurrentId=null;", + "const t=k=>k; const showToast=msg=>toasts.push(msg);", + "const $=()=>({classList:{contains:()=>false}});", + "const _updateYoloPill=()=>{pillUpdates+=1;};", + "const hideApprovalCard=()=>{cardHides+=1;};", + "const toggleYoloFromApproval=async()=>{throw new Error('unexpected card path');};", + f"const api={api_source};", + cmd_yolo, + "(async()=>{", + " await cmdYolo();", + f" if(JSON.stringify(calls)!==JSON.stringify({expected_calls!r})) throw new Error('wrong calls '+JSON.stringify(calls));", + " if(_yoloEnabled!==false) throw new Error('stale response changed new session state');", + " if(pillUpdates!==0) throw new Error('stale response updated the new session pill');", + " if(cardHides!==0) throw new Error('stale response hid the new session card');", + " if(toasts.length!==0) throw new Error('stale response emitted a toast');", + "})().catch(e=>{console.error(e.stack||e);process.exit(1)});", + ]) + result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True) + assert result.returncode == 0, result.stderr + + run( + "async path=>{calls.push(path);_loadSessionGeneration=2;return {yolo_enabled:false};}", + ["/api/session/yolo?session_id=old-session"], + ) + run( + "async (path,opts)=>{calls.push(path);if(path.includes('?'))return {yolo_enabled:false};_loadSessionGeneration=2;const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}", + ["/api/session/yolo?session_id=old-session", "/api/session/yolo"], + ) diff --git a/tests/test_issue4771_gateway_approval_failure_ui.py b/tests/test_issue4771_gateway_approval_failure_ui.py index b47f537e..4825f418 100644 --- a/tests/test_issue4771_gateway_approval_failure_ui.py +++ b/tests/test_issue4771_gateway_approval_failure_ui.py @@ -19,7 +19,18 @@ pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") def _extract_fn(src: str, name: str, prefix: str = "function ") -> str: start = src.index(f"{prefix}{name}(") - brace = src.index("{", start) + paren = src.index("(", start) + paren_depth = 0 + for i in range(paren, len(src)): + if src[i] == "(": + paren_depth += 1 + elif src[i] == ")": + paren_depth -= 1 + if paren_depth == 0: + brace = src.index("{", i + 1) + break + else: + raise AssertionError(f"{name} parameter list not closed") depth = 0 for i in range(brace, len(src)): if src[i] == "{": @@ -38,11 +49,12 @@ def _run_node(script: str) -> dict: try: result = subprocess.run( [NODE, script_path], - check=True, + check=False, capture_output=True, text=True, timeout=15, ) + assert result.returncode == 0, result.stderr return json.loads(result.stdout) finally: Path(script_path).unlink(missing_ok=True) @@ -60,7 +72,13 @@ def _run_failure_case(api_js: str) -> dict: _extract_fn(MESSAGES_JS, "_rememberApprovalPending"), _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"), _extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"), + _extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"), + _extract_fn(MESSAGES_JS, "_approvalOwnerForPending"), + _extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"), + _extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"), + _extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"), _extract_fn(MESSAGES_JS, "_approvalResponseMatches"), + _extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"), _extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"), _extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"), _extract_fn(MESSAGES_JS, "showApprovalCard"), @@ -85,7 +103,10 @@ let renderCalls = 0; let _approvalSessionId = 'sess-1'; let _approvalCurrentId = 'appr-1'; let _approvalPendingBySession = new Map(); +let _loadSessionGeneration = 1; let _approvalResponding = null; +let _approvalClearedOwner = null; +let _approvalDisplayedOwner = null; let _approvalSignature = ''; let _approvalVisibleSince = 0; let _approvalHideTimer = null; @@ -242,7 +263,13 @@ def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates(): _extract_fn(MESSAGES_JS, "_rememberApprovalPending"), _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"), _extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"), + _extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"), + _extract_fn(MESSAGES_JS, "_approvalOwnerForPending"), + _extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"), + _extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"), + _extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"), _extract_fn(MESSAGES_JS, "_approvalResponseMatches"), + _extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"), _extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"), _extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"), _extract_fn(MESSAGES_JS, "showApprovalCard"), @@ -262,7 +289,10 @@ const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals'; let _approvalSessionId = 'sess-1'; let _approvalCurrentId = 'appr-1'; let _approvalPendingBySession = new Map(); +let _loadSessionGeneration = 1; let _approvalResponding = null; +let _approvalClearedOwner = null; +let _approvalDisplayedOwner = null; let _approvalSignature = ''; let _approvalVisibleSince = 0; let _approvalHideTimer = null; diff --git a/tests/test_regressions.py b/tests/test_regressions.py index df8790af..28bf92ea 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -418,13 +418,18 @@ def test_respond_approval_uses_approval_session_id(cleanup_test_sessions): if the user switched while approval was pending). """ src = (REPO_ROOT / "static/messages.js").read_text() - # The fix introduces _approvalSessionId to track the correct session - assert "_approvalSessionId" in src, "messages.js must use _approvalSessionId in respondApproval" - # respondApproval must use _approvalSessionId, not S.session.session_id directly + # Click ownership is captured from the visible approval card, then passed + # immutably into respondApproval rather than re-read after an await. + capture_idx = src.find("function _captureApprovalResponseOwner(") + assert capture_idx >= 0, "approval response owner capture helper not found" + capture_body = src[capture_idx:capture_idx+500] + assert "const sid = _approvalSessionId" in capture_body + assert "const approvalId = _approvalCurrentId" in capture_body idx = src.find("async function respondApproval(") assert idx >= 0, "respondApproval not found" - fn_body = src[idx:idx+300] - assert "_approvalSessionId" in fn_body, "respondApproval must read _approvalSessionId, not S.session.session_id" + fn_body = src[idx:idx+500] + assert "options.owner || _captureApprovalResponseOwner()" in fn_body + assert "const {sid, approvalId} = owner" in fn_body # ── R11: Tool progress must not use shared status chrome ────────────────── diff --git a/tests/test_sprint30.py b/tests/test_sprint30.py index 5782d52e..678c0dd2 100644 --- a/tests/test_sprint30.py +++ b/tests/test_sprint30.py @@ -274,15 +274,15 @@ class TestApprovalMessagesJS: src = read(REPO / "static/messages.js") assert "_approvalResponseMatches" in src and "_setApprovalControlsDisabled(" in src, \ "showApprovalCard should route button state through the shared approval helper" - assert "responding ? _approvalResponding.choice : null" in src, \ - "showApprovalCard should preserve the active loading choice during rerenders" + assert "responding ? (_approvalResponding.controlChoice || _approvalResponding.choice) : null" in src, \ + "showApprovalCard should preserve the active control's loading state during rerenders" def test_respond_disables_buttons_immediately(self): src = read(REPO / "static/messages.js") - assert "_approvalResponding = {sid, approvalId: approvalId || null, choice};" in src, \ - "respondApproval should record the in-flight approval before the API call" - assert "_setApprovalControlsDisabled(choice, true);" in src, \ - "respondApproval should disable buttons immediately to prevent double-submit" + assert "_approvalResponding = {...owner, choice};" in src, \ + "respondApproval should record the immutable owner before the API call" + assert "_setApprovalControlsDisabled(controlChoice, true);" in src, \ + "respondApproval should disable buttons immediately using the clicked control target" def test_respond_uses_i18n_for_error(self): src = read(REPO / "static/messages.js")