Release v0.51.260 — Release IB (stage-r8): un-held safety fixes + cron/TTS/mcp/state-toast batch#3614
Conversation
Un-held safety fixes (author addressed my hold findings, re-reviewed fresh) + clean fix batch: - #3535 (@rodboev): self-update stash-pop recovery — NO data loss now (stash kept, ok:false, no restart on conflict). [was held; data-loss fix verified] - #1909 slice 3 (@rodboev): auth Secure cookie keyed on real TLS evidence only — no plain-HTTP LAN/Tailscale lockout; SameSite back to Lax. [was held; lockout fix verified] - #2785 (@franksong2702): clearer cron/gateway diagnostics for single-container Docker. - TTS sentence-boundary chunking for long responses (@lambyangzhao). - #3340 (@rly09): persistent-state toasts (memory saved / skill created-updated). - /reload-mcp dispatched as cli_only client command, not an LLM prompt (@franksong2702). Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com> Co-authored-by: rly09 <rly09@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| static/ui.js | TTS chunking refactor — _playEdgeTtsChunked never sets _ttsSpeaking=true before the async fetch, so the if(!_ttsSpeaking) return guard in the blob handler always fires, silently aborting Edge TTS playback. Browser TTS chunking looks correct. |
| api/auth.py | Cookie Secure-flag fix: X-Forwarded-Proto now requires explicit opt-in via HERMES_WEBUI_TRUST_FORWARDED_PROTO; plain-HTTP LAN/Tailscale no longer forced-Secure. _is_loopback helper is defined and tested but not called from the module. |
| api/updates.py | Stash-pop conflict recovery: git reset --merge added as cleanup step; stash is never dropped on conflict; restart is not scheduled on failure. Logic is correct and well-tested. |
| api/commands.py | New execute_agent_command with /reload-mcp allowlist, module-level _RELOAD_MCP_LOCK for serialization, and sanitized error messages. Logic and error handling look correct. |
| api/routes.py | Adds agent command dispatch before plugin command fallthrough; health detail fields forwarded in the status response. No issues found. |
| api/streaming.py | Adds _persistent_state_snapshot / _persistent_state_changes for backend-driven state_saved SSE events; lightweight mtime+size signatures, proper error suppression, and 10-skill cap. |
| static/messages.js | Adds persistent-state toast system (dual-channel: heuristic tool-name matching + backend state_saved SSE), deduplication via session-scoped Set with 200-entry LRU trim, and _AGENT_COMMANDS_RUN_ON_WEBUI intercept for /reload-mcp. |
| static/panels.js | Improved cron/gateway diagnostics — stale metadata and unreachable remote gateway now get distinct titles and guidance text. XSS-safe via esc(). |
Sequence Diagram
sequenceDiagram
participant Browser
participant messages.js
participant commands.js
participant API as /api/commands/exec
participant commands.py
participant mcp_tool as tools.mcp_tool
Browser->>messages.js: user types /reload-mcp
messages.js->>messages.js: _AGENT_COMMANDS_RUN_ON_WEBUI.has("reload-mcp")
messages.js->>commands.js: executeAgentCommand(text)
commands.js->>API: "POST {command: "/reload-mcp"}"
API->>commands.py: execute_agent_command("/reload-mcp")
commands.py->>commands.py: _normalize_agent_command_name to reload-mcp
commands.py->>commands.py: acquire _RELOAD_MCP_LOCK
commands.py->>mcp_tool: shutdown_mcp_servers()
commands.py->>mcp_tool: discover_mcp_tools()
commands.py->>commands.py: release _RELOAD_MCP_LOCK
commands.py-->>API: summary string
API-->>commands.js: "{output: "..."}"
commands.js-->>messages.js: output text
messages.js->>Browser: render assistant message
Reviews (1): Last reviewed commit: "Release v0.51.260 — Release IB (stage-r8..." | Re-trigger Greptile
| const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch')); | ||
| if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch)); | ||
| utter.onend=()=>{ | ||
| _ttsChunkIndex++; | ||
| if(_ttsChunkIndex<_ttsChunkQueue.length){ | ||
| const next=new SpeechSynthesisUtterance(_ttsChunkQueue[_ttsChunkIndex]); | ||
| next.voice=utter.voice; next.rate=utter.rate; next.pitch=utter.pitch; | ||
| next.onend=utter.onend; next.onerror=utter.onerror; | ||
| _ttsCurrentUtterance=next; | ||
| speechSynthesis.speak(next); | ||
| } else { | ||
| _ttsSpeaking=false; _ttsCurrentUtterance=null; | ||
| _ttsChunkQueue=[]; _ttsChunkIndex=0; _ttsActiveBtn=null; | ||
| if(btn) btn.dataset.speaking='0'; | ||
| } | ||
| }; | ||
| utter.onerror=()=>{ | ||
| _ttsSpeaking=false; _ttsCurrentUtterance=null; | ||
| _ttsChunkQueue=[]; _ttsChunkIndex=0; _ttsActiveBtn=null; | ||
| if(btn) btn.dataset.speaking='0'; | ||
| }; | ||
| return utter; | ||
| } | ||
|
|
||
| function _playEdgeTtsChunked(text, btn){ | ||
| const chunks=_splitForTTS(text); | ||
| const _playOne=function(idx){ | ||
| if(idx>=chunks.length){ | ||
| _ttsSpeaking=false;_playingEdgeAudio=null; | ||
| if(btn) btn.dataset.speaking='0'; | ||
| return; | ||
| } | ||
| const chunk=chunks[idx]; | ||
| const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural'; | ||
| const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate')); | ||
| const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch')); | ||
| let rate='', pitch=''; | ||
| if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';} | ||
| if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';} | ||
| fetch(new URL('api/tts', document.baseURI || location.href).href, { | ||
| method:'POST', | ||
| headers:{'Content-Type':'application/json'}, | ||
| body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch}) | ||
| }) | ||
| .then(function(r){ | ||
| if(!r.ok){ | ||
| return r.json().catch(function(){return {};}).then(function(j){ | ||
| throw new Error((j&&j.error)||('TTS request failed: '+r.status)); | ||
| }); | ||
| } | ||
| return r.blob(); | ||
| }) | ||
| .then(function(blob){ | ||
| if(!_ttsSpeaking) return; | ||
| const url=URL.createObjectURL(blob); | ||
| const audio=new Audio(url); | ||
| _playingEdgeAudio=audio; | ||
| audio.onended=function(){ | ||
| URL.revokeObjectURL(url); | ||
| _playingEdgeAudio=null; | ||
| if(_ttsSpeaking) _playOne(idx+1); | ||
| }; | ||
| audio.onerror=function(){ | ||
| URL.revokeObjectURL(url); | ||
| _playingEdgeAudio=null; | ||
| _ttsSpeaking=false; | ||
| if(btn) btn.dataset.speaking='0'; | ||
| }; | ||
| audio.play().catch(function(e){ | ||
| URL.revokeObjectURL(url); | ||
| _playingEdgeAudio=null; | ||
| _ttsSpeaking=false; | ||
| if(btn) btn.dataset.speaking='0'; | ||
| if(typeof showToast==='function') showToast('Edge TTS error: '+(e&&e.message||e)); | ||
| }); | ||
| }) | ||
| .catch(function(e){ | ||
| _ttsSpeaking=false;_playingEdgeAudio=null; | ||
| if(btn) btn.dataset.speaking='0'; | ||
| if(typeof showToast==='function') showToast('Edge TTS failed: '+(e&&e.message||e)); | ||
| }); | ||
| }; | ||
| _playOne(0); | ||
| } |
There was a problem hiding this comment.
Edge TTS state never set to speaking before playback
_playEdgeTtsChunked never sets _ttsSpeaking = true or btn.dataset.speaking = '1' before the async fetch starts. The original _playEdgeTts set both of these synchronously at the top of the function. In the new implementation, speakMessage calls stopTTS() first (which sets _ttsSpeaking = false) and then calls _playEdgeTtsChunked without resetting the flag. When the fetch resolves and the .then(function(blob){...}) handler runs, the if(!_ttsSpeaking) return; guard fires immediately and discards the audio blob — Edge TTS produces no sound at all. The btn.dataset.speaking indicator is also never set, so the UI doesn't reflect a speaking state. The same omission affects the autoReadLastAssistant() call path.
| def _is_loopback(addr: str) -> bool: | ||
| """Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x).""" | ||
| import ipaddress as _ipaddress | ||
| try: | ||
| ip = _ipaddress.ip_address(addr) | ||
| if ip.is_loopback: | ||
| return True | ||
| # Python < 3.12: is_loopback is False for ::ffff:127.x.x.x (gh-117566) | ||
| if hasattr(ip, 'ipv4_mapped') and ip.ipv4_mapped is not None: | ||
| return ip.ipv4_mapped.is_loopback | ||
| return False | ||
| except ValueError: | ||
| return False |
There was a problem hiding this comment.
_is_loopback is defined and fully tested, but it is never called anywhere in the module — _is_secure_context does not use it, and no other function in auth.py references it. If this helper was intentionally added for a future caller, a comment would clarify. If it was leftover from an earlier draft of the fix, it can be removed to avoid confusion.
| def _is_loopback(addr: str) -> bool: | |
| """Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x).""" | |
| import ipaddress as _ipaddress | |
| try: | |
| ip = _ipaddress.ip_address(addr) | |
| if ip.is_loopback: | |
| return True | |
| # Python < 3.12: is_loopback is False for ::ffff:127.x.x.x (gh-117566) | |
| if hasattr(ip, 'ipv4_mapped') and ip.ipv4_mapped is not None: | |
| return ip.ipv4_mapped.is_loopback | |
| return False | |
| except ValueError: | |
| return False | |
| def _is_loopback(addr: str) -> bool: | |
| """Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x). | |
| Not used by _is_secure_context; retained as a utility for callers that | |
| need to distinguish loopback from LAN addresses (e.g. rate-limiting, | |
| diagnostic endpoints). | |
| """ | |
| import ipaddress as _ipaddress | |
| try: | |
| ip = _ipaddress.ip_address(addr) | |
| if ip.is_loopback: | |
| return True | |
| # Python < 3.12: is_loopback is False for ::ffff:127.x.x.x (gh-117566) | |
| if hasattr(ip, 'ipv4_mapped') and ip.ipv4_mapped is not None: | |
| return ip.ipv4_mapped.is_loopback | |
| return False | |
| except ValueError: | |
| return False |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Release v0.51.260 — Release IB (stage-r8)
Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs.
Fixed
git reset --merge+git stash dropdiscarded local mods while reporting success). Now keeps the stash, returnsok:false+ "preserved instash@{0}", no restart on conflict. (was held — fix verified)Securecookie no longer locks out plain-HTTP LAN/Tailscale users. Secure now keys only on real TLS evidence (env / TLS socket / opt-inTRUST_FORWARDED_PROTO); non-loopback plain-HTTP is no longer force-Secure. SameSite back toLax. (was held — fix verified)/reload-mcpmarkedcli_onlyso the WebUI doesn't dispatch it as an LLM prompt.Gate
/reload-mcpallowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order.Co-authored-by: rodboev rodboev@users.noreply.github.com
Co-authored-by: franksong2702 franksong2702@users.noreply.github.com
Co-authored-by: lambyangzhao lambyangzhao@users.noreply.github.com
Co-authored-by: rly09 rly09@users.noreply.github.com