Skip to content

Release v0.51.260 — Release IB (stage-r8): un-held safety fixes + cron/TTS/mcp/state-toast batch#3614

Merged
nesquena-hermes merged 1 commit into
masterfrom
release/stage-r8
Jun 4, 2026
Merged

Release v0.51.260 — Release IB (stage-r8): un-held safety fixes + cron/TTS/mcp/state-toast batch#3614
nesquena-hermes merged 1 commit into
masterfrom
release/stage-r8

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

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

Issue/PR Author Fix
#3535 (#3538) @rodboev Self-update recovers from a stash-pop conflict without data loss. Was a BRICK bug (git reset --merge + git stash drop discarded local mods while reporting success). Now keeps the stash, returns ok:false + "preserved in stash@{0}", no restart on conflict. (was held — fix verified)
#1909 s3 (#3562) @rodboev Auth Secure cookie no longer locks out plain-HTTP LAN/Tailscale users. Secure now keys only on real TLS evidence (env / TLS socket / opt-in TRUST_FORWARDED_PROTO); non-loopback plain-HTTP is no longer force-Secure. SameSite back to Lax. (was held — fix verified)
#2785 (#3559) @franksong2702 Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire).
#3555 @lambyangzhao Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation).
#3340 (#3342) @rly09 Persistent-state toast when a turn has saved memory / created-updated a skill.
#3533 @franksong2702 /reload-mcp marked cli_only so the WebUI doesn't dispatch it as an LLM prompt.

Gate

  • Full pytest suite: 7681 passed, 0 failed
  • ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
  • Codex (regression): SAFE TO SHIP — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, /reload-mcp allowlisted, 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

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>
@nesquena-hermes nesquena-hermes merged commit ba98704 into master Jun 4, 2026
11 checks passed
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 4, 2026

Greptile Summary

This release bundles six fixes: a stash-pop conflict no longer bricks the self-update path, the auth Secure cookie no longer locks out plain-HTTP LAN/Tailscale users, /reload-mcp is routed through a narrow backend allowlist instead of the LLM, long TTS responses are chunked at sentence boundaries, the cron/gateway panel shows distinct diagnostics for stale metadata vs. unreachable remote gateways, and a persistent-state toast fires when a turn saves memory or creates/updates a skill.

  • api/updates.py: git reset --merge is attempted on stash-pop conflict; stash is never dropped, restart never scheduled on failure — two distinct error returns cover the "reset also failed" edge case.
  • api/auth.py: _is_secure_context now requires explicit HERMES_WEBUI_TRUST_FORWARDED_PROTO=1 opt-in before trusting X-Forwarded-Proto; plain-HTTP from any non-TLS source returns False.
  • static/ui.js: TTS chunking refactor introduces a regression for the Edge TTS engine — _playEdgeTtsChunked does not set _ttsSpeaking=true before the fetch, so the if(!_ttsSpeaking) return guard inside the blob handler aborts playback silently on every call.

Confidence Score: 3/5

Most of the batch is safe, but the Edge TTS engine path is completely broken by the chunking refactor — users who have Edge TTS selected will get no audio output.

Five of the six fixes (stash recovery, auth cookie, /reload-mcp routing, cron diagnostics, state toast) are well-implemented and well-tested. The TTS chunking change silently breaks Edge TTS for all users: _playEdgeTtsChunked omits the _ttsSpeaking=true setup that the old _playEdgeTts did synchronously, so the if(!_ttsSpeaking) return guard inside the fetch .then callback immediately aborts audio playback on every call. Browser TTS chunking is unaffected. The fix is a two-line add at the top of _playEdgeTtsChunked.

static/ui.js — the _playEdgeTtsChunked function needs _ttsSpeaking=true and btn.dataset.speaking='1' added at its entry point before the fetch starts.

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "Release v0.51.260 — Release IB (stage-r8..." | Re-trigger Greptile

Comment thread static/ui.js
Comment on lines +4572 to +4655
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread api/auth.py
Comment on lines +543 to +555
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _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.

Suggested change
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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant