Release — approval-card YOLO handoff fix (#6945) (#7122)

* 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 <dss539@users.noreply.github.com>
Co-authored-by: n <a@n>
This commit is contained in:
authored and GitHub committed 2026-08-18 12:41:36 +00:00
1 parent b1c2bea92e
commit cced4f3509
15 files changed
+2992 -212

No files matched your search

+1
View File
@@ -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)
+78 -3
View File
@@ -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
+222 -8
View File
@@ -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.
+428 -110
View File
@@ -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:
+9 -4
View File
@@ -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)
+15
View File
@@ -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
+36 -8
View File
@@ -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 ──
+195 -62
View File
@@ -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;
}
}
+4 -2
View File
@@ -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, \
+4 -2
View File
@@ -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, \
File diff suppressed because it is too large. Load diff
+43
View File
@@ -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"],
)
@@ -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;
+10 -5
View File
@@ -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 ──────────────────
+6 -6
View File
@@ -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")