* 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:
6 files changed
+317
-23
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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 == []
|
||||
@@ -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
|
||||
Reference in new issue
Block a user