diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be70c27..c0298f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## [Unreleased] +## [v0.51.565] — 2026-06-21 — Release TX (API-server sidecar prune + mic insecure-origin message) + +### Fixed + +- **API-server sidecar sessions are now pruned from the sidebar when their backing agent session is deleted.** Previously the orphan-prune path only reconciled imported *CLI* sidecars; an API-server session that was opened in WebUI (creating a read-only sidecar) lingered forever after the backing agent row was deleted outside WebUI, with no delete affordance. The prune now also covers API-server sidecars, while still protecting native WebUI-owned sessions and failing closed (rows are kept if the agent state DB can't be probed). Thanks @franksong2702. (#4619) +- **Voice dictation now explains insecure-origin failures instead of showing a generic "microphone access denied".** When Hermes is opened over plain `http://` on a non-local origin, the browser blocks microphone access at the origin level; the UI previously surfaced this as a permission error. It now detects insecure non-local origins (preserving HTTPS, `localhost`, `.localhost`, and IPv4/IPv6 loopback) and shows a clear "needs a secure connection — open over HTTPS or localhost" message, and preflights the check before starting capture. Thanks @franksong2702. (#4621) + ## [v0.51.564] — 2026-06-21 — Release TW (loopback sidecar diagnostics) ### Added diff --git a/api/routes.py b/api/routes.py index 58404f4d..2a4ef2b9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1827,12 +1827,12 @@ def _build_session_list_cache_payload( cli = get_cli_sessions(source_filter=source_filter, all_profiles=all_profiles) diag_stage("merge_cli_sessions") cli_by_id = {s["session_id"]: s for s in cli} - # #3238: reconcile orphaned imported-CLI sidecars. When a CLI - # session was clicked in WebUI it gets a WebUI-owned sidecar that - # all_sessions() returns independently of state.db. If the user - # later deletes the backing CLI session from the command line, - # the sidecar is never pruned and the stale row lingers in the - # sidebar forever (there is no WebUI delete affordance for it). + # #3238/#4591: reconcile orphaned imported sidecars. When a CLI or + # API-server session is clicked in WebUI it gets a WebUI-owned sidecar + # that all_sessions() returns independently of state.db. If the user + # later deletes the backing agent session outside WebUI, the sidecar is + # never pruned and the stale row lingers in the sidebar forever (there + # is no WebUI delete affordance for read-only imported rows). # Drop rows whose backing agent row is genuinely gone. We probe # state.db directly (agent_session_rows_existing) rather than trust # cli_by_id absence, because get_cli_sessions() caps at @@ -1846,7 +1846,7 @@ def _build_session_list_cache_payload( _sid = s.get("session_id") if ( _sid - and is_cli_session_row(s) + and (is_cli_session_row(s) or _is_api_server_sidecar_row(s)) and not _session_source_is_webui(s) and _sid not in cli_by_id ): @@ -1879,11 +1879,11 @@ def _build_session_list_cache_payload( prune_session_from_index(_sid) except Exception: logger.debug( - "Failed to prune orphaned CLI sidecar %s", + "Failed to prune orphaned agent sidecar %s", _sid, exc_info=True, ) - diag_stage("prune_orphaned_cli_sidecar") + diag_stage("prune_orphaned_agent_sidecar") continue _kept_after_orphan_prune.append(s) webui_sessions = _kept_after_orphan_prune @@ -6095,6 +6095,24 @@ def _session_source_is_webui(session: dict) -> bool: return False +def _normalized_source_marker(value) -> str: + marker = str(value or "").strip().lower() + if marker.endswith(" session"): + marker = marker[:-len(" session")].strip() + return marker.replace("-", "_").replace(" ", "_") + + +def _is_api_server_sidecar_row(session: dict) -> bool: + """Return True for API-server imported sidecars that need orphan pruning.""" + if not isinstance(session, dict) or _session_source_is_webui(session): + return False + markers = { + _normalized_source_marker(session.get(key)) + for key in ("source", "source_tag", "raw_source", "session_source", "source_label") + } + return bool(markers & {"api", "api_server"}) + + def _session_lineage_ids(session: dict) -> set[str]: """Return known ids that identify one logical sidebar lineage.""" if not isinstance(session, dict): diff --git a/static/boot.js b/static/boot.js index 9b399cd4..a32ad8ff 100644 --- a/static/boot.js +++ b/static/boot.js @@ -463,6 +463,36 @@ $('mainChat')?.addEventListener('pointerdown', closeMobileWorkspacePanelFromChat $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInput').value='';$('fileInput').click();}; // ── Voice input (Web Speech API + MediaRecorder fallback) ─────────────────── +function _micIsLocalhostOrLoopback(hostname){ + const host=String(hostname||'').toLowerCase().replace(/^\[|\]$/g,''); + return host==='localhost' + || host.endsWith('.localhost') + || host==='::1' + || host==='0:0:0:0:0:0:0:1' + || /^127\./.test(host); +} + +function _micOriginNeedsSecureContext(){ + if(window.isSecureContext===true) return false; + const loc=window.location||{}; + const protocol=loc.protocol||''; + return protocol==='http:'&&!_micIsLocalhostOrLoopback(loc.hostname); +} + +function _micToastKeyForRecognitionError(error){ + if((error==='not-allowed'||error==='service-not-allowed'||error==='audio-capture') + && _micOriginNeedsSecureContext()){ + return 'mic_insecure_origin'; + } + const msgs={ + 'not-allowed':'mic_denied', + 'service-not-allowed':'mic_denied', + 'no-speech':'mic_no_speech', + 'network':'mic_network', + }; + return msgs[error]||null; +} + (function(){ const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; const _canRecordAudio=!!(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia&&window.MediaRecorder); @@ -677,12 +707,8 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu _forceMediaRecorder=true; recognition=null; } - const msgs={ - 'not-allowed':t('mic_denied'), - 'no-speech':t('mic_no_speech'), - 'network':t('mic_network'), - }; - showToast(msgs[event.error]||t('mic_error')+event.error); + const messageKey=_micToastKeyForRecognitionError(event.error); + showToast(messageKey?t(messageKey):t('mic_error')+event.error); }; return sr; @@ -739,6 +765,12 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu _isRecording=true; _finalText=''; _prefix=ta.value; + if(_micOriginNeedsSecureContext()){ + _isRecording=false; + window._micPendingSend=false; + showToast(t('mic_insecure_origin')); + return; + } if(recognition && !_forceMediaRecorder && !_rawAudioMode){ _activeCaptureMode='speech'; recognition.start(); @@ -788,7 +820,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu _isRecording=false; window._micPendingSend=false; _stopTracks(); - showToast(t('mic_denied')); + showToast(t(_micToastKeyForRecognitionError('not-allowed')||'mic_denied')); } }; @@ -907,6 +939,11 @@ window._micPendingSend=window._micPendingSend||false; function _startListening(){ if(!_voiceModeActive) return; + if(_micOriginNeedsSecureContext()){ + _deactivate(); + showToast(t('mic_insecure_origin')); + return; + } _clearBrowserTtsRecovery(); _setState('listening'); @@ -962,7 +999,8 @@ window._micPendingSend=window._micPendingSend||false; } if(event.error==='not-allowed'||event.error==='service-not-allowed'||event.error==='audio-capture'){ _deactivate(); - showToast(t('mic_denied')); + const messageKey=_micToastKeyForRecognitionError(event.error); + showToast(messageKey?t(messageKey):t('mic_error')+event.error); return; } // Other errors — try to restart @@ -1197,6 +1235,10 @@ window._micPendingSend=window._micPendingSend||false; }; function _activate(){ + if(_micOriginNeedsSecureContext()){ + showToast(t('mic_insecure_origin')); + return; + } _voiceModeActive=true; modeBtn.classList.add('active'); _setButtonTooltip(modeBtn, t('voice_mode_toggle_active')); diff --git a/static/i18n.js b/static/i18n.js index 53b4c460..e6698391 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -19,6 +19,7 @@ const LOCALES = { cancelling: 'Cancelling\u2026', 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.', mic_network: 'Speech recognition unavailable.', mic_error: 'Voice input error: ', @@ -1588,6 +1589,7 @@ const LOCALES = { cancelling: 'Annullamento\u2026', cancel_failed: 'Annullamento fallito: ', mic_denied: 'Accesso al microfono negato. Controlla i permessi del browser.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Nessuna voce rilevata. Riprova.', mic_network: 'Riconoscimento vocale non disponibile.', mic_error: 'Errore input vocale: ', @@ -3148,6 +3150,7 @@ const LOCALES = { cancelling: 'キャンセル中…', cancel_failed: 'キャンセル失敗: ', mic_denied: 'マイクへのアクセスが拒否されました。ブラウザの権限を確認してください。', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: '音声が検出されませんでした。もう一度お試しください。', mic_network: '音声認識を利用できません。', mic_error: '音声入力エラー: ', @@ -4712,6 +4715,7 @@ const LOCALES = { cancelling: 'Отменяю…', cancel_failed: 'Не удалось отменить: ', mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.', mic_network: 'Распознавание речи недоступно.', mic_error: 'Ошибка ввода речи: ', @@ -6215,6 +6219,7 @@ const LOCALES = { cancelling: 'Cancelando…', cancel_failed: 'Error al cancelar: ', mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.', mic_network: 'El reconocimiento de voz no está disponible.', mic_error: 'Error de entrada por voz: ', @@ -7711,6 +7716,7 @@ const LOCALES = { cancelling: 'Wird abgebrochen\u2026', cancel_failed: 'Abbrechen fehlgeschlagen: ', mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.', mic_network: 'Spracherkennung nicht verfügbar.', mic_error: 'Spracheingabefehler: ', @@ -9211,6 +9217,7 @@ const LOCALES = { cancelling: '正在取消...', cancel_failed: '取消失败:', mic_denied: '麦克风访问被拒绝,请检查浏览器权限。', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: '没有检测到语音,请再试一次。', mic_network: '语音识别当前不可用。', mic_error: '语音输入出错:', @@ -10706,6 +10713,7 @@ const LOCALES = { cancelling: '正在取消……', cancel_failed: '取消失敗:', mic_denied: '麥克風存取遭拒。請檢查瀏覽器權限。', + mic_insecure_origin: '語音輸入需要安全連線。請透過 HTTPS 或 localhost 開啟 Hermes 才能使用麥克風。', mic_no_speech: '未偵測到語音。請再試一次。', mic_network: '語音辨識無法使用。', mic_error: '語音輸入錯誤:', @@ -12268,6 +12276,7 @@ const LOCALES = { cancelling: 'Cancelando…', cancel_failed: 'Falha ao cancelar: ', mic_denied: 'Acesso ao microfone negado. Verifique as permissões do navegador.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Nenhuma fala detectada. Tente novamente.', mic_network: 'Reconhecimento de fala indisponível.', mic_error: 'Erro no input de voz: ', @@ -13650,6 +13659,7 @@ const LOCALES = { cancelling: '취소 중\u2026', cancel_failed: '취소 실패: ', mic_denied: '마이크 접근이 거부되었습니다. 브라우저 권한을 확인하세요.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: '음성이 감지되지 않았습니다. 다시 시도하세요.', mic_network: '음성 인식을 사용할 수 없습니다.', mic_error: '음성 입력 오류: ', @@ -15199,6 +15209,7 @@ const LOCALES = { cancelling: 'Annulation\u2026', cancel_failed: 'Échec de l\'annulation :', mic_denied: 'Accès au microphone refusé. Vérifiez les autorisations du navigateur.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Aucune parole détectée. Essayer à nouveau.', mic_network: 'Reconnaissance vocale indisponible.', mic_error: 'Erreur de saisie vocale :', @@ -16699,6 +16710,7 @@ const LOCALES = { cancelling: 'İptal ediliyor\u2026', cancel_failed: 'İptal başarısız oldu:', mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', mic_network: 'Konuşma tanıma kullanılamıyor.', mic_error: 'Ses girişi hatası:', @@ -18250,6 +18262,7 @@ const LOCALES = { cancelling: 'Anulowanie…', cancel_failed: 'Anulowanie nie powiodło się: ', mic_denied: 'Odmowa dostępu do mikrofonu. Sprawdź uprawnienia przeglądarki.', + mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', // TODO: translate mic_no_speech: 'Nie wykryto mowy. Spróbuj ponownie.', mic_network: 'Rozpoznawanie mowy jest niedostępne.', mic_error: 'Błąd wejścia głosowego: ', @@ -20056,4 +20069,3 @@ function applyLocaleToDOM() { // Apply saved locale immediately so there's no flash of English on reload. loadLocale(); - diff --git a/tests/test_issue3238_orphaned_cli_sidecar_prune.py b/tests/test_issue3238_orphaned_cli_sidecar_prune.py index 4b98275a..20c9a2c6 100644 --- a/tests/test_issue3238_orphaned_cli_sidecar_prune.py +++ b/tests/test_issue3238_orphaned_cli_sidecar_prune.py @@ -1,5 +1,8 @@ -"""Regression coverage for #3238 — WebUI does not sync when CLI sessions are -deleted outside WebUI. +"""Regression coverage for orphaned imported sidecar pruning. + +#3238: WebUI does not sync when CLI sessions are deleted outside WebUI. +#4591: API-server sidecars are read-only and accumulated indefinitely after +their backing state.db row was deleted. When a CLI/agent session is clicked in the WebUI sidebar it gets a WebUI-owned sidecar (`webui/sessions/.json` + an `_index.json` row) so it can render and @@ -8,9 +11,9 @@ be reopened. From then on `all_sessions()` returns it independently of the agent storage, nothing prunes the orphaned sidecar, so the stale row lingers in the sidebar forever — there is no WebUI delete affordance for CLI rows. -The fix probes `state.db` directly via `agent_session_row_exists()` (an exact, -uncapped existence check) and prunes the sidecar only when the backing row is -genuinely gone. It must NOT rely on the session's presence in +The fix probes `state.db` directly via `agent_session_rows_existing()` (an +exact, uncapped existence check) and prunes the sidecar only when the backing +row is genuinely gone. It must NOT rely on the session's presence in `get_cli_sessions()`, which caps at `CLI_VISIBLE_SESSION_LIMIT` (20) — an existing session can fall out of that window and look deleted. """ @@ -203,3 +206,101 @@ def test_cli_row_present_in_cli_by_id_is_not_pruned(): row = {"session_id": "cli-live", "source_tag": "cli", "is_cli_session": True} cli_by_id = {"cli-live": {"session_id": "cli-live"}} assert _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn=lambda sid: False) is False + + +def _payload_for_rows(monkeypatch, rows, existing_ids): + import api.routes as routes + + pruned = [] + + monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows)) + monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []) + monkeypatch.setattr( + routes, + "_reconcile_stale_stream_state_for_session_rows", + lambda _sessions: False, + ) + monkeypatch.setattr( + routes, + "agent_session_rows_existing", + lambda ids, profile=None: frozenset(existing_ids), + ) + monkeypatch.setattr(routes, "prune_session_from_index", lambda sid: pruned.append(sid)) + + payload = routes._build_session_list_cache_payload( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + ) + return payload, pruned + + +def test_orphaned_api_server_sidecar_is_pruned_from_sidebar_payload(monkeypatch): + """API-server sidecars take the same exact state.db orphan prune path.""" + row = { + "session_id": "api-orphan", + "title": "API Session", + "profile": "default", + "updated_at": 20, + "last_message_at": 20, + "message_count": 2, + "read_only": True, + "source_tag": "api_server", + "raw_source": "api_server", + "session_source": "api", + "source_label": "API", + "is_cli_session": False, + } + + payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=[]) + + assert [session["session_id"] for session in payload["sessions"]] == [] + assert pruned == ["api-orphan"] + + +def test_api_server_sidecar_with_backing_state_row_is_retained(monkeypatch): + """Absence from get_cli_sessions() alone is not enough to prune API rows.""" + row = { + "session_id": "api-live", + "title": "API Session", + "profile": "default", + "updated_at": 20, + "last_message_at": 20, + "message_count": 2, + "read_only": True, + "source_tag": "api_server", + "raw_source": "api_server", + "session_source": "api", + "source_label": "API", + "is_cli_session": False, + } + + payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=["api-live"]) + + assert [session["session_id"] for session in payload["sessions"]] == ["api-live"] + assert pruned == [] + + +def test_webui_owned_session_with_api_metadata_is_not_pruned(monkeypatch): + """A native WebUI row must stay safe even if stale metadata mentions API.""" + row = { + "session_id": "webui-native", + "title": "Native WebUI", + "profile": "default", + "updated_at": 20, + "last_message_at": 20, + "message_count": 2, + "read_only": False, + "source_tag": "webui", + "raw_source": "api_server", + "session_source": "webui", + "source_label": "API", + "is_cli_session": False, + } + + payload, pruned = _payload_for_rows(monkeypatch, [row], existing_ids=[]) + + assert [session["session_id"] for session in payload["sessions"]] == ["webui-native"] + assert pruned == [] diff --git a/tests/test_issue4571_mic_insecure_origin.py b/tests/test_issue4571_mic_insecure_origin.py new file mode 100644 index 00000000..5f478b2f --- /dev/null +++ b/tests/test_issue4571_mic_insecure_origin.py @@ -0,0 +1,114 @@ +"""Regression tests for #4571 insecure-origin microphone messaging. + +Browser speech/microphone APIs can report permission-style errors when the real +problem is that the page was opened over insecure HTTP from a non-local origin. +The UI must explain the HTTPS/localhost requirement instead of only saying the +browser permission was denied. +""" +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + +def _locale_count() -> int: + return len(re.findall(r"^ ['\"]?[\w-]+['\"]?: \{", I18N_JS, re.MULTILINE)) + + +def _slice_between(src: str, start_marker: str, end_marker: str) -> str: + start = src.index(start_marker) + end = src.index(end_marker, start) + return src[start:end] + + +def test_i18n_key_exists_in_every_locale_and_names_https_localhost(): + assert I18N_JS.count("mic_insecure_origin") == _locale_count() + + english = re.search(r"mic_insecure_origin: '([^']+)'", I18N_JS) + assert english, "English mic_insecure_origin string must exist" + value = english.group(1).lower() + assert "https" in value + assert "localhost" in value + assert "microphone" in value + + +def test_secure_context_and_localhost_loopback_are_explicitly_preserved(): + assert "function _micOriginNeedsSecureContext()" in BOOT_JS + assert "window.isSecureContext===true" in BOOT_JS + assert "protocol==='http:'" in BOOT_JS + assert "!_micIsLocalhostOrLoopback(loc.hostname)" in BOOT_JS + + localhost_helper = _slice_between( + BOOT_JS, + "function _micIsLocalhostOrLoopback(hostname)", + "\n}\n\nfunction _micOriginNeedsSecureContext", + ) + assert "host==='localhost'" in localhost_helper + assert "host.endsWith('.localhost')" in localhost_helper + assert "host==='::1'" in localhost_helper + assert "/^127\\./.test(host)" in localhost_helper + + +def test_permission_style_speech_errors_use_insecure_origin_key_only_on_that_branch(): + helper = _slice_between( + BOOT_JS, + "function _micToastKeyForRecognitionError(error)", + "\n}\n\n(function(){", + ) + for error in ("not-allowed", "service-not-allowed", "audio-capture"): + assert error in helper + assert "_micOriginNeedsSecureContext()" in helper + assert "return 'mic_insecure_origin';" in helper + assert "'audio-capture':'mic_denied'" not in helper + assert "'network':'mic_network'" in helper + assert "'no-speech':'mic_no_speech'" in helper + + +def test_dictation_preflights_insecure_origin_before_speech_or_media_capture(): + body = _slice_between( + BOOT_JS, + "btn.onclick=async()=>{", + "\n // Wire up the settings checkbox", + ) + insecure_idx = body.index("_micOriginNeedsSecureContext()") + speech_idx = body.index("recognition.start()") + media_idx = body.index("navigator.mediaDevices.getUserMedia") + + assert insecure_idx < speech_idx + assert insecure_idx < media_idx + assert "showToast(t('mic_insecure_origin'))" in body + + +def test_dictation_speechrecognition_errors_route_through_shared_helper(): + body = _slice_between( + BOOT_JS, + "sr.onerror=(event)=>{", + "\n return sr;", + ) + assert "_micToastKeyForRecognitionError(event.error)" in body + assert "messageKey?t(messageKey):t('mic_error')+event.error" in body + + +def test_voice_mode_preflights_and_routes_permission_errors_through_shared_helper(): + activate_body = _slice_between( + BOOT_JS, + "function _activate(){", + "\n function _deactivate(){", + ) + assert "_micOriginNeedsSecureContext()" in activate_body + assert "showToast(t('mic_insecure_origin'))" in activate_body + assert "_voiceModeActive=true" in activate_body + assert activate_body.index("_micOriginNeedsSecureContext()") < activate_body.index("_voiceModeActive=true") + + start_body = _slice_between( + BOOT_JS, + "function _startListening(){", + "\n function _voiceModeSend(){", + ) + assert "_micOriginNeedsSecureContext()" in start_body + assert "showToast(t('mic_insecure_origin'))" in start_body + assert "_micToastKeyForRecognitionError(event.error)" in start_body + assert "messageKey?t(messageKey):t('mic_error')+event.error" in start_body