fix(gateway): fall back safely without approval identities

This commit is contained in:
Rod Boev committed 2026-07-22 12:31:12 -04:00
1 parent b481dfea13
commit f30178e18a
8 files changed
+65 -77

No files matched your search

+7
View File
@@ -8892,6 +8892,7 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
caps = {
"approval_events": False,
"run_approval_response": False,
"approval_identity_v1": False,
"capabilities_reachable": False,
"probe_error": None,
"fetched_at": 0.0,
@@ -8909,6 +8910,7 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
features = {}
caps["approval_events"] = bool(features.get("approval_events"))
caps["run_approval_response"] = bool(features.get("run_approval_response"))
caps["approval_identity_v1"] = bool(features.get("approval_identity_v1"))
except urllib.error.HTTPError as exc:
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
@@ -8948,6 +8950,11 @@ def gateway_supports_approval(base_url: str, api_key: str = "") -> bool:
return bool(caps.get("approval_events") and caps.get("run_approval_response"))
def gateway_supports_approval_identity_v1(base_url: str, api_key: str = "") -> bool:
"""True only when Gateway advertises authoritative approval identities."""
return bool(get_gateway_caps(base_url, api_key).get("approval_identity_v1"))
def invalidate_gateway_caps(base_url: str | None = None) -> None:
"""Evict capability cache for base_url, or all entries when base_url is None."""
with _GATEWAY_CAPS_LOCK:
+2 -3
View File
@@ -470,7 +470,8 @@ def _run_gateway_runs_api_streaming(
if stop_confirmed:
put_gateway_event("cancel", {"message": "Cancelled by user"})
return None, usage
cancel_event.clear()
put_gateway_event("apperror", {"message": "Gateway stop failed"})
return None, usage
url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events"
headers_sse = dict(headers)
@@ -504,8 +505,6 @@ def _run_gateway_runs_api_streaming(
approval_data = _gateway_runs_approval_event(payload)
if approval_data:
approval_data["run_id"] = run_id
if not approval_data.get("approval_id"):
approval_data["approval_id"] = f"gwrun:{run_id}:{uuid.uuid4().hex}"
from api.route_approvals import submit_gateway_pending_mirror
head, total = submit_gateway_pending_mirror(session_id, approval_data)
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
+15 -27
View File
@@ -13378,15 +13378,13 @@ def handle_get(handler, parsed) -> bool:
run_id = _STREAM_RUN_IDS.get(stream_id)
pending_run_id = False
if not run_id:
import time as _time
pending_run_id = gateway_run_id_pending(stream_id)
attempts_remaining = 10
while not run_id and pending_run_id and attempts_remaining > 0:
_time.sleep(0.05)
run_id = _STREAM_RUN_IDS.get(stream_id)
pending_run_id = gateway_run_id_pending(stream_id)
attempts_remaining -= 1
if pending_run_id:
cancel_flag = CANCEL_FLAGS.get(stream_id)
if cancel_flag:
cancel_flag.set()
cancelled = cancel_stream(stream_id)
return j(handler, {"ok": True, "cancelled": cancelled, "deferred": True, "stream_id": stream_id})
if run_id:
if stop_gateway_run(stream_id):
owner_sid = stream_owner_session_id(stream_id)
@@ -13394,8 +13392,6 @@ def handle_get(handler, parsed) -> bool:
retire_gateway_pending_mirror(owner_sid, run_id=run_id)
else:
gateway_stop_blocked = True
elif pending_run_id:
gateway_stop_blocked = True
except Exception:
logger.debug("Failed to stop gateway run during chat cancellation", exc_info=True)
gateway_stop_blocked = True
@@ -23955,11 +23951,11 @@ def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool
if approval_id:
for entry in entries:
if isinstance(entry, dict) and entry.get("approval_id") == approval_id:
return bool(entry.get(_GATEWAY_MIRROR_FLAG))
return bool(entry.get(_GATEWAY_MIRROR_FLAG)) and not str(entry.get("run_id") or "").strip()
return False
if not entries or not isinstance(entries[0], dict):
return False
return bool(entries[0].get(_GATEWAY_MIRROR_FLAG))
return bool(entries[0].get(_GATEWAY_MIRROR_FLAG)) and not str(entries[0].get("run_id") or "").strip()
def _session_has_pending_approval(sid: str) -> bool:
@@ -24002,7 +23998,7 @@ def _handle_approval_respond(handler, body):
_gateway_api_key,
webui_gateway_chat_enabled,
)
from api.config import get_config as _get_config
from api.config import get_config as _get_config, gateway_supports_approval_identity_v1
s = get_session(sid)
_candidate_run_id = None
if s is not None:
@@ -24109,8 +24105,9 @@ def _handle_approval_respond(handler, body):
_base = _gateway_base_url(_cfg)
_key = _gateway_api_key()
try:
identity_v1 = gateway_supports_approval_identity_v1(_base, _key)
HttpRunnerClient(base_url=_base, api_key=_key).respond_approval(
_run_id, matched_mirror["approval_id"], choice
_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)
@@ -24125,22 +24122,13 @@ def _handle_approval_respond(handler, body):
return j(handler, {"ok": False, "choice": choice, "relayed": False,
"code": "gateway_run_unavailable",
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409)
# Only a still-mirrored gateway approval with a missing run should 409;
# stale or empty gateway clicks fall through to local resolution.
# A no-run mirror is local visibility state only. Retire it instead of
# claiming that a remote approval can still be resolved.
if webui_gateway_chat_enabled(_get_config()) and _gateway_pending_approval_without_run_id(
sid, approval_id
):
return j(
handler,
{
"ok": False,
"choice": choice,
"relayed": False,
"code": "gateway_run_unavailable",
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
},
status=409,
)
retire_gateway_pending_mirror(sid, approval_id=approval_id)
return j(handler, {"ok": True, "choice": choice, "local_retired": True})
except Exception:
pass # fall through to local approval path
+15 -15
View File
@@ -17,7 +17,7 @@ const LOCALES = {
_speech: 'en-US',
// boot.js
cancelling: 'Cancelling\u2026',
cancel_failed: 'Cancel failed: ',
cancel_failed: 'Cancel failed.',
mic_denied: 'Microphone access denied. Check browser permissions.',
mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.',
mic_no_speech: 'No speech detected. Try again.',
@@ -1777,7 +1777,7 @@ const LOCALES = {
_speech: 'it-IT',
// boot.js
cancelling: 'Annullamento\u2026',
cancel_failed: 'Annullamento fallito: ',
cancel_failed: 'Annullamento fallito.',
mic_denied: 'Accesso al microfono negato. Controlla i permessi del browser.',
mic_insecure_origin: 'L\'input vocale richiede una connessione sicura. Apri Hermes tramite HTTPS o da localhost per usare il microfono.',
mic_no_speech: 'Nessuna voce rilevata. Riprova.',
@@ -3519,7 +3519,7 @@ const LOCALES = {
_speech: 'ja-JP',
// boot.js
cancelling: 'キャンセル中…',
cancel_failed: 'キャンセル失敗: ',
cancel_failed: 'キャンセルに失敗しました。',
mic_denied: 'マイクへのアクセスが拒否されました。ブラウザの権限を確認してください。',
mic_insecure_origin: '音声入力にはセキュアな接続が必要です。マイクを使うには HTTPS または localhost 経由で Hermes を開いてください。',
mic_no_speech: '音声が検出されませんでした。もう一度お試しください。',
@@ -5265,7 +5265,7 @@ const LOCALES = {
_label: 'Русский',
_speech: 'ru-RU',
cancelling: 'Отменяю…',
cancel_failed: 'Не удалось отменить: ',
cancel_failed: 'Не удалось отменить.',
mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.',
mic_insecure_origin: 'Для голосового ввода нужно защищённое соединение. Откройте Hermes по HTTPS или с localhost, чтобы использовать микрофон.',
mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.',
@@ -6987,7 +6987,7 @@ const LOCALES = {
_speech: 'es-ES',
// boot.js
cancelling: 'Cancelando…',
cancel_failed: 'Error al cancelar: ',
cancel_failed: 'Error al cancelar.',
mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.',
mic_insecure_origin: 'La entrada de voz necesita una conexión segura. Abre Hermes con HTTPS o desde localhost para usar el micrófono.',
mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.',
@@ -8675,7 +8675,7 @@ const LOCALES = {
_speech: 'de-DE',
// boot.js
cancelling: 'Wird abgebrochen\u2026',
cancel_failed: 'Abbrechen fehlgeschlagen: ',
cancel_failed: 'Abbrechen fehlgeschlagen.',
mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.',
mic_insecure_origin: 'Für die Spracheingabe ist eine sichere Verbindung erforderlich. Öffne Hermes über HTTPS oder von localhost, um das Mikrofon zu verwenden.',
mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.',
@@ -10357,7 +10357,7 @@ const LOCALES = {
_speech: 'zh-CN',
// boot.js
cancelling: '正在取消...',
cancel_failed: '取消失败:',
cancel_failed: '取消失败。',
mic_denied: '麦克风访问被拒绝,请检查浏览器权限。',
mic_insecure_origin: '语音输入需要安全连接。请通过 HTTPS 协议或从本地主机打开Hermes,以便使用麦克风。',
mic_no_speech: '没有检测到语音,请再试一次。',
@@ -12034,7 +12034,7 @@ const LOCALES = {
_speech: 'zh-TW',
// boot.js
cancelling: '正在取消……',
cancel_failed: '取消失敗:',
cancel_failed: '取消失敗。',
mic_denied: '麥克風存取遭拒。請檢查瀏覽器權限。',
mic_insecure_origin: '語音輸入需要安全連線。請透過 HTTPS 或 localhost 開啟 Hermes 才能使用麥克風。',
mic_no_speech: '未偵測到語音。請再試一次。',
@@ -13778,7 +13778,7 @@ const LOCALES = {
_speech: 'pt-BR',
// boot.js
cancelling: 'Cancelando…',
cancel_failed: 'Falha ao cancelar: ',
cancel_failed: 'Falha ao cancelar.',
mic_denied: 'Acesso ao microfone negado. Verifique as permissões do navegador.',
mic_insecure_origin: 'A entrada de voz precisa de uma conexão segura. Abra o Hermes por HTTPS ou a partir do localhost para usar o microfone.',
mic_no_speech: 'Nenhuma fala detectada. Tente novamente.',
@@ -15340,7 +15340,7 @@ const LOCALES = {
_speech: 'ko-KR',
// boot.js
cancelling: '취소 중\u2026',
cancel_failed: '취소 실패: ',
cancel_failed: '취소에 실패했습니다.',
mic_denied: '마이크 접근이 거부되었습니다. 브라우저 권한을 확인하세요.',
mic_insecure_origin: '음성 입력에는 보안 연결이 필요합니다. 마이크를 사용하려면 HTTPS 또는 localhost를 통해 Hermes를 여세요.',
mic_no_speech: '음성이 감지되지 않았습니다. 다시 시도하세요.',
@@ -17071,7 +17071,7 @@ const LOCALES = {
_label: 'Français',
_speech: 'fr-FR',
cancelling: 'Annulation\u2026',
cancel_failed: 'Échec de l\'annulation : ',
cancel_failed: 'Échec de l\'annulation.',
mic_denied: 'Accès au microphone refusé. Vérifiez les autorisations du navigateur.',
mic_insecure_origin: 'La saisie vocale nécessite une connexion sécurisée. Ouvrez Hermes en HTTPS ou depuis localhost pour utiliser le microphone.',
mic_no_speech: 'Aucune parole détectée. Réessayez.',
@@ -20000,7 +20000,7 @@ const LOCALES = {
btw_no_answer: 'Nebyla přijata žádná odpověď.',
busy_interrupt_confirm: 'Přerušeno — odeslání nové zprávy',
busy_steer_fallback: 'Řízení není k dispozici — návrh byl obnoven',
cancel_failed: 'Zrušení selhalo: ',
cancel_failed: 'Zrušení selhalo.',
cancel_unavailable: 'Zrušení není k dispozici.',
cancelling: 'Rušení\u2026',
clarify_heading: 'Zde je zapotřebí vyjasnění.',
@@ -20492,7 +20492,7 @@ const LOCALES = {
_speech: 'tr-TR',
// boot.js
cancelling: 'İptal ediliyor\u2026',
cancel_failed: 'İptal başarısız oldu:',
cancel_failed: 'İptal başarısız oldu.',
mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.',
mic_insecure_origin: 'Sesli giriş güvenli bir bağlantı gerektirir. Mikrofonu kullanmak için Hermes\'i HTTPS üzerinden veya localhost\'tan açın.',
mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.',
@@ -22225,7 +22225,7 @@ const LOCALES = {
_speech: 'pl-PL',
// boot.js
cancelling: 'Anulowanie…',
cancel_failed: 'Anulowanie nie powiodło się: ',
cancel_failed: 'Anulowanie nie powiodło się.',
mic_denied: 'Odmowa dostępu do mikrofonu. Sprawdź uprawnienia przeglądarki.',
mic_insecure_origin: 'Wprowadzanie głosowe wymaga bezpiecznego połączenia. Otwórz Hermes przez HTTPS lub z localhost, aby użyć mikrofonu.',
mic_no_speech: 'Nie wykryto mowy. Spróbuj ponownie.',
@@ -23962,7 +23962,7 @@ const LOCALES = {
_speech: 'vi-VN',
// boot.js
cancelling: 'Đang hủy…',
cancel_failed: 'Hủy thất bại: ',
cancel_failed: 'Hủy thất bại.',
mic_denied: 'Quyền truy cập microphone bị từ chối. Kiểm tra quyền trình duyệt.',
mic_no_speech: 'Không phát hiện giọng nói. Hãy thử lại.',
mic_network: 'Nhận diện giọng nói không khả dụng.',
+1
View File
@@ -2315,6 +2315,7 @@ async function loadSession(sid){
setComposerStatus('');
updateQueueBadge(sid);
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
startApprovalPolling(sid);
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
// Workspace refresh is guarded by session id inside loadDir(); keep it
// after the transcript's first paint so chat switching is not competing
+20 -23
View File
@@ -489,7 +489,7 @@ def test_gateway_runs_api_streaming_same_run_fifo_emits_head_and_promotes_succes
})
handler.send_response.assert_called_with(200)
respond_approval.assert_called_once_with("run-fifo", "approval-first", "deny")
respond_approval.assert_called_once_with("run-fifo", "", "deny")
promoted = approvals.gateway_pending_mirror(sid, run_id="run-fifo")
assert promoted is not None
assert promoted["approval_id"] == "approval-second"
@@ -639,7 +639,7 @@ def test_gateway_approval_response_relay_cleans_only_selected_shared_id_run():
})
handler.send_response.assert_called_with(200)
respond_approval.assert_called_once_with("run-live", "same-id", "once")
respond_approval.assert_called_once_with("run-live", "", "once")
assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-live") is None
assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-old") is not None
finally:
@@ -723,7 +723,7 @@ def test_gateway_approval_relay_does_not_unblock_unrelated_local_head():
})
handler.send_response.assert_called_with(200)
respond_approval.assert_called_once_with("run-remote", "remote-a", "once")
respond_approval.assert_called_once_with("run-remote", "", "once")
assert local_entry.event.is_set() is False
assert local_entry.result is None
assert approvals.gateway_pending_mirror(sid, approval_id="remote-a", run_id="run-remote") is None
@@ -1207,7 +1207,7 @@ def test_runs_api_cancel_after_run_creation_stops_remote_run_before_events():
_STREAM_RUN_IDS.pop(stream_id, None)
def test_runs_api_cancel_after_run_creation_keeps_stream_open_when_stop_fails():
def test_runs_api_cancel_after_run_creation_reports_stop_failure():
from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming
stream_id = "sid-run-create-cancel-stop-failed"
@@ -1272,14 +1272,11 @@ def test_runs_api_cancel_after_run_creation_keeps_stream_open_when_stop_fails():
cancel_event=cancel_event,
cfg={},
)
assert final_text == "Hello"
assert final_text is None
assert usage == {}
stop_run.assert_called_once_with(stream_id)
assert requests == [
"http://gw:8642/v1/runs",
"http://gw:8642/v1/runs/run-pre-events-stop-failed/events",
]
assert [event for event, _data in events] == ["token"]
assert requests == ["http://gw:8642/v1/runs"]
assert [event for event, _data in events] == ["apperror"]
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
@@ -1457,7 +1454,7 @@ def test_chat_cancel_surfaces_redirected_gateway_stop(monkeypatch):
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True))
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True)
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: False)
def fake_j(handler, data, status=200, extra_headers=None):
@@ -1539,7 +1536,7 @@ def test_chat_cancel_surfaces_gateway_stop_failure(monkeypatch):
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True))
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True)
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: False)
def fake_j(handler, data, status=200, extra_headers=None):
@@ -1565,7 +1562,7 @@ def test_chat_cancel_surfaces_gateway_stop_failure(monkeypatch):
approvals._pending.pop(sid, None)
def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch):
def test_chat_cancel_defers_while_gateway_run_id_is_pending(monkeypatch):
import urllib.parse
from api import routes
@@ -1575,7 +1572,7 @@ def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch):
called = {"cancel": False, "stop": False}
monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True)
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True))
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True)
monkeypatch.setattr("api.gateway_chat.gateway_run_id_pending", lambda _stream_id: True)
monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: called.__setitem__("stop", True))
monkeypatch.setattr("time.sleep", lambda _seconds: None)
@@ -1588,18 +1585,18 @@ def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch):
monkeypatch.setattr(routes, "j", fake_j)
parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}")
routes.handle_get(object(), parsed)
assert captured["status"] == 502
assert captured["status"] == 200
assert captured["payload"] == {
"ok": False,
"cancelled": False,
"ok": True,
"cancelled": True,
"deferred": True,
"stream_id": stream_id,
"error": "Gateway stop failed",
}
assert called["cancel"] is False
assert called["cancel"] is True
assert called["stop"] is False
def test_chat_cancel_waits_for_pending_gateway_run_id_then_stops_remote_run(monkeypatch):
def test_chat_cancel_defers_stop_until_gateway_run_id_exists(monkeypatch):
import urllib.parse
from api import routes
@@ -1629,8 +1626,8 @@ def test_chat_cancel_waits_for_pending_gateway_run_id_then_stops_remote_run(monk
try:
routes.handle_get(object(), parsed)
assert captured["status"] == 200
assert captured["payload"] == {"ok": True, "cancelled": True, "stream_id": stream_id}
assert called["stop"] is True
assert captured["payload"] == {"ok": True, "cancelled": True, "deferred": True, "stream_id": stream_id}
assert called["stop"] is False
assert called["cancel"] is True
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
@@ -2130,7 +2127,7 @@ def test_gateway_approval_response_relay():
_handle_approval_respond(handler, body)
assert captured.get("url", "") == "http://gw:8642/v1/runs/run%20abc%2F1/approval"
assert captured["body"] == {"choice": "once", "approval_id": "appr x/y"}
assert captured["body"] == {"choice": "once", "approval_id": ""}
handler.send_response.assert_called_with(200)
# Cleanup.
@@ -381,10 +381,8 @@ def test_local_plain_pending_without_id_beats_active_remote_candidate_run():
_cleanup(sid)
def test_gateway_mirrored_approval_without_run_still_409s():
"""#4771 behaviour preserved: on the gateway backend, a mirrored approval
whose run is gone must still surface the actionable 409 so the card stays
visible instead of silently failing."""
def test_gateway_mirrored_approval_without_run_retires_locally():
"""A gateway mirror without a run retires instead of leaving a ghost card."""
sid = f"gw-approval-{uuid.uuid4().hex[:8]}"
_register_session(sid) # no active_stream_id -> no _STREAM_RUN_IDS mapping
try:
@@ -396,10 +394,8 @@ def test_gateway_mirrored_approval_without_run_still_409s():
{"session_id": sid, "choice": "once", "approval_id": approval_id},
)
resp = handler.json()
assert handler.status == 409, f"expected 409, got {handler.status}: {resp}"
assert resp.get("ok") is False
assert resp.get("relayed") is False
assert resp.get("code") == "gateway_run_unavailable"
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp == {"ok": True, "choice": "once", "local_retired": True}
finally:
_cleanup(sid)
+1 -1
View File
@@ -148,7 +148,7 @@ def test_attention_summary_keeps_direct_runs_mirror_after_stream_pointer_clears(
})
handler.send_response.assert_called_with(200)
respond_approval.assert_called_once_with("run-retained", "approval-retained", "deny")
respond_approval.assert_called_once_with("run-retained", "", "deny")
assert json.loads(handler.wfile.getvalue().decode("utf-8"))["ok"] is True
finally:
approvals._pending.pop(sid, None)