Release v0.51.565 — API-server sidecar prune (#4619) + mic insecure-origin message (#4621) (#4639)

* stage batch #4619+#4621 (franksong2702): API-server sidecar orphan-prune + mic insecure-origin message

#4619 (fixes #4591): widen the #3238 orphan-prune to also reconcile API-server
imported sidecars (api/routes.py _is_api_server_sidecar_row). Webui-source guard
intact, fail-closed probe (agent_session_rows_existing degrades to assume-present).
#4621 (fixes #4571): detect insecure non-local http origins for voice dictation +
hands-free, show mic_insecure_origin message, preflight before capture. mic_insecure_origin
added to all 13 locale blocks (zh-Hant real translation + English TODO fallbacks).
Disjoint files (backend routes vs frontend boot/i18n). Code byte-faithful from PR heads.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

* Release v0.51.565 — Release TX (API-server sidecar prune #4619 + mic insecure-origin #4621)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-21 16:50:32 -07:00
1 parent 0fd9bf3c37
commit b4c434fb65
6 files changed
+317 -23

No files matched your search

+7
View File
@@ -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
+27 -9
View File
@@ -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):
+50 -8
View File
@@ -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'));
+13 -1
View File
@@ -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();
@@ -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/<id>.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 == []
+114
View File
@@ -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