Release v0.51.260 — Release IB (stage-r8) (#3614)
Some checks failed
Release & Docker / release (push) Has been cancelled

## 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>
This commit is contained in:
nesquena-hermes
2026-06-04 15:21:41 -07:00
committed by GitHub
parent efbb0a5bda
commit ba987040c7
20 changed files with 1183 additions and 113 deletions

View File

@@ -3,6 +3,16 @@
## [Unreleased]
## [v0.51.260] — 2026-06-04 — Release IB (stage-r8 — un-held safety fixes + cron/TTS/mcp/state-toast batch)
### Fixed
- **Self-update recovers safely from a stash-pop conflict without losing local changes.** Previously the conflict path ran `git reset --merge` then `git stash drop`, permanently discarding the user's local modifications while reporting success. It now keeps the stash, returns a clear "your changes are preserved in `stash@{0}`" message, and does not restart on conflict. (#3535, @rodboev)
- **Auth cookie `Secure` flag no longer locks out plain-HTTP LAN/Tailscale users.** `Secure` is now driven only by real TLS evidence (`HERMES_WEBUI_SECURE`, a TLS socket, or the opt-in `HERMES_WEBUI_TRUST_FORWARDED_PROTO` + `X-Forwarded-Proto`); a non-loopback plain-HTTP client is no longer force-marked Secure (which made browsers drop the cookie → login loop). SameSite stays `Lax`. (#1909 slice 3, @rodboev)
- **Clearer cron/gateway diagnostics for single-container Docker deployments** where gateway-backed chat is configured but no gateway daemon runs, so scheduled jobs silently don't fire. (#2785, @franksong2702)
- **Long TTS responses no longer cut off partway.** Long assistant text is chunked at sentence boundaries before `SpeechSynthesis.speak()` / `/api/tts`, working around the browser's ~32K silent-truncation limit. (@lambyangzhao)
- **Persistent-state changes are now visible in chat.** A small success toast appears when an agent turn has **saved memory** or **created/updated a skill** during a normal WebUI turn. (#3340, @rly09)
- **`/reload-mcp` now runs as a client command instead of falling through to the LLM as a prompt.** It is exposed by `/api/commands` and appeared in slash autocomplete but wasn't marked `cli_only`, so the WebUI dispatched it as a normal message. (@franksong2702)
## [v0.51.259] — 2026-06-04 — Release IA (stage-r7 — edge-TTS Content-Length + orphaned tool_calls strip)
### Fixed

View File

@@ -299,10 +299,11 @@ def _runtime_detail_subset(runtime_status: dict[str, Any] | None) -> dict[str, A
# reachable remote gateway. The Tasks/Cron banner then shows a spurious amber
# "Gateway not configured" warning.
#
# When ``HERMES_API_URL`` is set we treat that as an explicit declaration that
# the gateway lives elsewhere, and probe it over HTTP before touching any local
# filesystem / module signal. The probe result is cached briefly so a dashboard
# rerender that fans out to multiple panels does not hammer the gateway.
# When a gateway base URL is set in any supported env var, we treat that as an
# explicit declaration that the gateway lives elsewhere, and probe it over HTTP
# before touching any local filesystem / module signal. The probe result is
# cached briefly so a dashboard rerender that fans out to multiple panels does
# not hammer the gateway.
_REMOTE_PROBE_TIMEOUT_S: float = 2.0
_REMOTE_PROBE_CACHE_TTL_S: float = 5.0
@@ -318,7 +319,8 @@ _remote_probe_cache: dict[str, Any] = {"url": None, "expires_at": 0.0, "result":
def _remote_gateway_base_url() -> str | None:
"""Return an explicit remote gateway base URL, or None for local-only setups.
Priority: GATEWAY_HEALTH_URL > HERMES_GATEWAY_HEALTH_URL > HERMES_API_URL.
Priority: GATEWAY_HEALTH_URL > HERMES_GATEWAY_HEALTH_URL > HERMES_API_URL
> HERMES_WEBUI_GATEWAY_BASE_URL.
Returns ``None`` when no env var is set so the caller falls through to
local PID/state checks.
@@ -328,7 +330,12 @@ def _remote_gateway_base_url() -> str | None:
health-path suffix first so we don't build ``/health/health/detailed``
(mirrors the normalization in api/updates.py).
"""
for var in ("GATEWAY_HEALTH_URL", "HERMES_GATEWAY_HEALTH_URL", "HERMES_API_URL"):
for var in (
"GATEWAY_HEALTH_URL",
"HERMES_GATEWAY_HEALTH_URL",
"HERMES_API_URL",
"HERMES_WEBUI_GATEWAY_BASE_URL",
):
val = os.environ.get(var, "").strip()
if val:
base = val.rstrip("/")

View File

@@ -540,21 +540,36 @@ def check_auth(handler, parsed) -> bool:
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)."""
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_secure_context(handler=None) -> bool:
"""Return True if cookies should carry the Secure flag.
Behaviour is overridable via HERMES_WEBUI_SECURE env var for
reverse-proxy setups where TLS terminates at a frontend proxy
(nginx, Cloudflare, etc.) and Python only sees plain HTTP.
1/true/yes → force Secure on; 0/false/no → force Secure off.
When unset, fall back to heuristics: direct TLS socket (getpeercert)
or X-Forwarded-Proto header from the request.
Priority order:
1. ``HERMES_WEBUI_SECURE`` env var: 1/true/yes -> True; 0/false/no -> False.
2. Direct TLS socket (handler.request.getpeercert present) -> True.
3. ``HERMES_WEBUI_TRUST_FORWARDED_PROTO=1`` opt-in: trust
``X-Forwarded-Proto: https`` header from a known reverse proxy.
4. Otherwise -> False (loopback or non-loopback, plain HTTP is not secure).
.. warning::
The ``X-Forwarded-Proto`` header is only trustworthy when a
reverse proxy (nginx, Cloudflare, etc.) is deployed in front
of the application. Without a proxy, any client can forge the
header and cause the Secure flag to be set on plain HTTP.
``X-Forwarded-Proto`` is only trustworthy behind a reverse proxy.
It is ignored unless ``HERMES_WEBUI_TRUST_FORWARDED_PROTO=1`` is
set explicitly, preventing header-injection attacks on plain-HTTP
deployments.
"""
env = os.getenv('HERMES_WEBUI_SECURE', '').strip().lower()
if env in ('1', 'true', 'yes'):
@@ -564,8 +579,10 @@ def _is_secure_context(handler=None) -> bool:
if handler is not None:
if getattr(handler.request, 'getpeercert', None) is not None:
return True
if handler.headers.get('X-Forwarded-Proto', '') == 'https':
return True
trust_fwd = os.getenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', '').strip().lower()
if trust_fwd in ('1', 'true', 'yes'):
if handler.headers.get('X-Forwarded-Proto', '') == 'https':
return True
return False

View File

@@ -6,6 +6,7 @@ so the frontend can still load with WEBUI_ONLY commands.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
@@ -20,6 +21,30 @@ _NEVER_EXPOSE: frozenset[str] = frozenset({
})
# Narrow agent-side execution allowlist for /api/commands/exec.
_AGENT_COMMAND_ALIASES = {
'reload_mcp': 'reload-mcp',
}
_ALLOWED_AGENT_COMMANDS = frozenset({'reload-mcp'})
_RELOAD_MCP_LOCK = threading.Lock()
def _normalize_agent_command_name(command: str) -> str:
"""Normalize slash text to a canonical command name."""
raw = str(command or "").strip()
if not raw:
raise ValueError("command is required")
cmd_text = raw[1:] if raw.startswith("/") else raw
cmd_parts = cmd_text.split(maxsplit=1)
cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower()
if not cmd_base:
raise ValueError("command is required")
return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base)
def list_commands(_registry=None) -> list[dict[str, Any]]:
"""Return COMMAND_REGISTRY entries as JSON-friendly dicts.
@@ -74,10 +99,67 @@ def list_commands(_registry=None) -> list[dict[str, Any]]:
})
except Exception:
pass
return out
def execute_agent_command(command: str) -> str:
"""Execute a narrow allowlist of agent-side runtime commands."""
canonical = _normalize_agent_command_name(command)
if canonical not in _ALLOWED_AGENT_COMMANDS:
raise KeyError(canonical)
if canonical == 'reload-mcp':
return _run_reload_mcp_command()
raise KeyError(canonical)
def _run_reload_mcp_command() -> str:
"""Execute the MCP reconnect path and return a short user-facing summary."""
with _RELOAD_MCP_LOCK:
try:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
except Exception as exc:
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
raise RuntimeError("MCP runtime unavailable") from exc
try:
with _lock:
old_servers = set(_servers.keys())
shutdown_mcp_servers()
new_tools = discover_mcp_tools()
with _lock:
connected_servers = set(_servers.keys())
except Exception as exc:
logger.warning("Failed to reload MCP servers", exc_info=True)
raise RuntimeError("Failed to reload MCP servers") from exc
added = connected_servers - old_servers
removed = old_servers - connected_servers
reconnected = connected_servers & old_servers
lines = ["Reloaded MCP servers from configuration."]
if reconnected:
lines.append(f"Reconnected: {', '.join(sorted(reconnected))}")
if added:
lines.append(f"Added: {', '.join(sorted(added))}")
if removed:
lines.append(f"Removed: {', '.join(sorted(removed))}")
if connected_servers:
lines.append(f"{len(new_tools or [])} tool(s) available across {len(connected_servers)} server(s)")
else:
lines.append("No MCP servers connected")
if not reconnected and not added and not removed:
lines.append("Tooling state was already current")
return "\n".join(lines)
def execute_plugin_command(command: str) -> str:
"""Execute a plugin-registered slash command and return printable output.
@@ -103,12 +185,14 @@ def execute_plugin_command(command: str) -> str:
resolve_plugin_command_result,
)
except ImportError as exc:
logger.warning("Plugin command runtime unavailable", exc_info=True)
raise RuntimeError("plugin command runtime unavailable") from exc
try:
handler = get_plugin_command_handler(cmd_base)
except Exception as exc:
raise RuntimeError(f"plugin command lookup failed: {exc}") from exc
logger.warning("Plugin command lookup failed for %r", cmd_base, exc_info=True)
raise RuntimeError("plugin command lookup failed") from exc
if not handler:
raise KeyError(cmd_base)
@@ -120,5 +204,5 @@ def execute_plugin_command(command: str) -> str:
# Don't leak raw exception str (paths, env, internal state) to the
# user-facing chat. Type name is enough for the user to know what
# class of failure occurred; full traceback lives in the server log.
logger.warning("Plugin command %r failed", cmd_base, exc_info=exc)
logger.warning("Plugin command %r execution failed", cmd_base, exc_info=True)
return f"Plugin command error: {type(exc).__name__}"

View File

@@ -5902,6 +5902,10 @@ def handle_get(handler, parsed) -> bool:
# setup is probably not configured with a gateway
health = build_agent_health_payload()
alive = health.get("alive")
details = health.get("details") if isinstance(health.get("details"), dict) else {}
health_reason = details.get("reason")
health_state = details.get("state")
health_gateway_state = details.get("gateway_state")
if alive is True:
running = True
configured = True
@@ -5924,10 +5928,9 @@ def handle_get(handler, parsed) -> bool:
# configured" rather than nagging (#1944). So stale-stopped falls
# through to the identity_map signal like the genuinely-unconfigured
# case.
details = health.get("details") or {}
gateway_running_metadata = (
details.get("reason") == "gateway_stale_running_state"
or details.get("gateway_state") == "running"
health_reason == "gateway_stale_running_state"
or health_gateway_state == "running"
)
configured = True if gateway_running_metadata else bool(identity_map)
running = bool(identity_map)
@@ -5963,6 +5966,11 @@ def handle_get(handler, parsed) -> bool:
"platforms": platforms,
"last_active": last_active,
"session_count": len(identity_map),
"health": {
"state": health_state,
"reason": health_reason,
"gateway_state": health_gateway_state,
},
})
# ── MCP Servers (GET) ──
@@ -7205,11 +7213,21 @@ def handle_post(handler, parsed) -> bool:
# ── Commands (POST) ──
if parsed.path == "/api/commands/exec":
from api.commands import execute_plugin_command
from api.commands import execute_agent_command, execute_plugin_command
command = str(body.get("command", "") or "").strip()
if not command:
return bad(handler, "command is required")
try:
return j(handler, {"output": execute_agent_command(command)})
except KeyError:
pass
except ValueError as e:
return bad(handler, str(e), 400)
except RuntimeError as e:
return bad(handler, _sanitize_error(e), 500)
try:
return j(handler, {"output": execute_plugin_command(command)})
except ValueError as e:

View File

@@ -63,6 +63,68 @@ _ENV_LOCK = threading.Lock()
_KEYLESS_CUSTOM_API_KEY = "dummy-key"
_PERSISTENT_MEMORY_FILES = (
("memory", ("memories", "MEMORY.md")),
("user", ("memories", "USER.md")),
("soul", ("SOUL.md",)),
)
def _file_signature(path: Path) -> tuple[int, int] | None:
try:
st = path.stat()
return (int(st.st_mtime_ns), int(st.st_size))
except OSError:
return None
def _persistent_state_snapshot(profile_home: str | None) -> dict:
"""Capture lightweight memory/skill file signatures for save toasts."""
if not profile_home:
return {"memory": {}, "skills": {}}
root = Path(profile_home)
memory = {}
for key, parts in _PERSISTENT_MEMORY_FILES:
sig = _file_signature(root.joinpath(*parts))
if sig is not None:
memory[key] = sig
skills = {}
skills_dir = root / "skills"
try:
for skill_md in skills_dir.rglob("SKILL.md"):
try:
rel = str(skill_md.relative_to(skills_dir)).replace("\\", "/")
except ValueError:
rel = str(skill_md)
sig = _file_signature(skill_md)
if sig is not None:
skills[rel] = sig
except OSError:
pass
return {"memory": memory, "skills": skills}
def _persistent_state_changes(before: dict | None, after: dict | None) -> dict:
before = before or {"memory": {}, "skills": {}}
after = after or {"memory": {}, "skills": {}}
memory_before = before.get("memory") or {}
memory_after = after.get("memory") or {}
skills_before = before.get("skills") or {}
skills_after = after.get("skills") or {}
memory_changed = any(memory_before.get(key) != sig for key, sig in memory_after.items())
skills = []
for rel, sig in skills_after.items():
old_sig = skills_before.get(rel)
if old_sig == sig:
continue
name = Path(rel).parent.name or Path(rel).stem
skills.append({
"name": name,
"path": rel,
"action": "created" if old_sig is None else "updated",
})
return {"memory_saved": memory_changed, "skills": skills[:10]}
def _resolve_custom_provider_runtime_overrides(
resolved_provider: str | None,
@@ -5576,6 +5638,7 @@ def _run_agent_streaming(
if _process_notifications:
_agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip()
user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg)
_persistent_state_before = _persistent_state_snapshot(_profile_home)
result = agent.run_conversation(
user_message=user_message,
system_message=workspace_system_msg,
@@ -6475,6 +6538,26 @@ def _run_agent_streaming(
mark_turn_completed(s.session_id, agent=agent)
except Exception:
logger.debug("Memory lifecycle mark failed for session %s", s.session_id, exc_info=True)
try:
_persistent_changes = _persistent_state_changes(
_persistent_state_before,
_persistent_state_snapshot(_profile_home),
)
if _persistent_changes.get("memory_saved"):
put("state_saved", {
"session_id": session_id,
"kind": "memory",
"action": "saved",
})
for _skill_change in _persistent_changes.get("skills") or []:
put("state_saved", {
"session_id": session_id,
"kind": "skill",
"action": _skill_change.get("action") or "updated",
"name": _skill_change.get("name") or "",
})
except Exception:
logger.debug("Persistent state change detection failed for session %s", s.session_id, exc_info=True)
# Sync to state.db for /insights (opt-in setting)
try:
from api.config import load_settings as _load_settings

View File

@@ -1257,9 +1257,25 @@ def _apply_update_inner(target):
if stashed:
_, pop_ok = _run_git(['stash', 'pop'], path)
if not pop_ok:
_, reset_ok = _run_git(['reset', '--merge'], path)
if not reset_ok:
return {
'ok': False,
'message': (
'Updated successfully, but failed to clean up a '
'stash-pop conflict. Manual intervention needed: '
'run git reset --merge in ' + str(path)
),
'stash_conflict': True,
}
return {
'ok': False,
'message': 'Updated but stash pop failed -- manual merge needed',
'message': (
f'{target} updated to the latest version, but your local '
'modifications conflict with upstream changes. Your changes '
'are preserved in stash@{0}. To re-apply them: '
'git -C ' + str(path) + ' stash pop, then resolve conflicts.'
),
'stash_conflict': True,
}

View File

@@ -68,10 +68,16 @@ isolated Hermes home and follow
## Scheduled jobs and the gateway daemon
**Symptom**: Cron jobs created in the Tasks panel never fire. System Settings shows the orange "Gateway not configured" pill, and the Tasks panel shows the same banner above the job list.
**Symptom**: Cron jobs created in the Tasks panel never fire. System Settings or Tasks shows:
- Orange "Gateway not configured", or
- Red "Gateway metadata stale" when runtime metadata is stale, or
- Red "Gateway endpoint not reachable" when WebUI has a gateway URL configured but cannot reach its health endpoint.
**Cause**: Scheduled cron ticks are not driven by the WebUI itself. The gateway daemon ticks the scheduler every 60 seconds; without one running, scheduled jobs sit idle. "Run now" / "Trigger" buttons still work because the WebUI handles those in-process.
In older gateway builds, or when the daemon runs in a separate container, `gateway_state.json` can become stale and WebUI may lose confidence even if the daemon is up. This is especially visible if only base URLs are configured (e.g. `HERMES_WEBUI_GATEWAY_BASE_URL`) and local daemon state files are not being refreshed.
**Fix**: Run a gateway container alongside the WebUI. The two-container compose file is the recommended path:
```bash
@@ -84,10 +90,13 @@ The three-container layout adds the dashboard but is otherwise the same shape. I
**Verify**: Once the gateway is up, the System Settings pill should turn green and the Tasks banner disappear. From the host:
```bash
export GATEWAY_BASE_URL="${HERMES_API_URL:-${HERMES_WEBUI_GATEWAY_BASE_URL:-http://hermes:8642}}"
docker compose -f docker-compose.two-container.yml exec hermes-agent hermes gateway status
curl -sS "${GATEWAY_BASE_URL%/}/health/detailed" | jq '.gateway_state, .state'
```
If the service name differs in your compose file, `docker compose -f docker-compose.two-container.yml ps` lists the running services.
For container-to-container diagnostics, set one of `HERMES_API_URL` or `HERMES_WEBUI_GATEWAY_BASE_URL` in the WebUI environment when using gateway chat mode (`HERMES_WEBUI_CHAT_BACKEND=gateway`), then restart WebUI.
Refs #2785.

View File

@@ -243,7 +243,15 @@ function cliOnlyCommandResponse(cmdName, meta){
return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`;
}
async function executeAgentCommand(text,_meta){
return _runAgentCommandTransport(text,_meta);
}
async function executeAgentPluginCommand(text,_meta){
return _runAgentCommandTransport(text,_meta);
}
async function _runAgentCommandTransport(text,_meta){
const command=String(text||'').trim();
if(!command) throw new Error('command is required');
const data=await api('/api/commands/exec',{

View File

@@ -74,6 +74,94 @@ if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window
let _selectedTextReplyBtn=null;
let _selectedTextReplyText='';
let _selectedTextReplyRaf=0;
const _persistentStateToastSeen=new Set();
function _persistentToastText(value){
if(value===null||value===undefined)return '';
if(typeof value==='string')return value;
try{return JSON.stringify(value);}catch(_){return String(value||'');}
}
function _persistentToastToolName(tool){
return String(tool&&tool.name||'').trim();
}
function _persistentToastArgs(tool){
const args=tool&&tool.args;
return args&&typeof args==='object'?args:{};
}
function _persistentToastPreview(tool){
return [
_persistentToastText(tool&&tool.preview),
_persistentToastText(tool&&tool.snippet),
].filter(Boolean).join('\n');
}
function _persistentToastHasWriteIntent(name, text){
const nameWords=String(name||'').replace(/_/g,' ');
const haystack=`${nameWords}\n${text}`.toLowerCase();
if(/\b(read|list|view|search|lookup|get|fetch|load|usage|toggle|delete|remove)\b/.test(nameWords))return false;
if(/\b(no|not|nothing)\s+(?:was\s+)?(?:saved|updated|created|written|stored|changed)\b/.test(haystack))return false;
if(/\b(?:unchanged|skipped|dry[- ]run|failed|error)\b/.test(haystack))return false;
return /\b(save|saved|write|wrote|written|update|updated|create|created|store|stored|persist|persisted|remember|remembered)\b/.test(haystack);
}
function _persistentToastSkillName(tool){
const args=_persistentToastArgs(tool);
const raw=args.name||args.skill_name||args.skill||args.title||'';
const direct=String(raw||'').trim();
if(direct)return direct;
const text=_persistentToastPreview(tool);
const match=text.match(/\bskill(?:\s+updated|\s+created|\s+saved)?\s*[:=]\s*["'`]?([A-Za-z0-9_.-]{2,80})/i);
return match?match[1]:'';
}
function _maybeNotifyPersistentStateSaved(tool){
if(!tool||tool.is_error||typeof showToast!=='function')return;
const name=_persistentToastToolName(tool);
if(!name)return;
const nameKey=name.toLowerCase().replace(/[^a-z0-9]+/g,'_');
const preview=_persistentToastPreview(tool);
const argsText=_persistentToastText(_persistentToastArgs(tool));
const text=`${preview}\n${argsText}`;
if(!_persistentToastHasWriteIntent(nameKey, text))return;
const nameWords=nameKey.replace(/_/g,' ');
const isSkill=/\bskills?\b/.test(nameWords);
const isMemory=/\b(memory|memories|remember|profile)\b/.test(nameWords);
if(!isSkill&&!isMemory)return;
const skillName=isSkill?_persistentToastSkillName(tool):'';
if(isSkill&&!skillName)return;
_showPersistentStateToast(isSkill?'skill':'memory', skillName, {
created: isSkill&&/\b(create|created|new)\b/.test(`${nameKey}\n${preview}`.toLowerCase()),
});
}
function _showPersistentStateToast(kind, name, options){
if(typeof showToast!=='function')return;
const normalizedKind=String(kind||'').toLowerCase();
if(normalizedKind!=='skill'&&normalizedKind!=='memory')return;
const itemName=String(name||'').trim();
const dedupeKey=[
S&&S.session&&S.session.session_id||'',
normalizedKind,
itemName||'memory',
].join(':');
if(_persistentStateToastSeen.has(dedupeKey))return;
_persistentStateToastSeen.add(dedupeKey);
if(_persistentStateToastSeen.size>200){
const first=_persistentStateToastSeen.values().next().value;
_persistentStateToastSeen.delete(first);
}
if(normalizedKind==='skill'){
const base=options&&options.created?t('skill_created'):t('skill_updated');
showToast(itemName?`${base}: ${itemName}`:base,4200,'success');
return;
}
showToast(t('memory_saved'),3600,'success');
}
function _selectedTextReplyT(key, fallback){
try{
@@ -208,6 +296,11 @@ if(typeof document!=='undefined'){
let _sendInProgress = false;
let _sendInProgressSid = null; // session_id of the in-flight send
const _sessionTitleProvisionalBySid = new Map();
// Agent commands that are safe to execute directly in the WebUI even though
// their canonical command is registered on the backend (for example
// /reload-mcp). Keep this intentionally narrow and include underscore variants
// observed by users so typing either form still routes through executeAgentCommand.
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp']);
function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
if(!S||!S.busy||compressionRunning) return false;
@@ -423,6 +516,22 @@ async function send(){
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
const _agentCmdName=String(_agentCmd&&_agentCmd.name||_parsedCmd&&_parsedCmd.name||'').trim().toLowerCase();
if(_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName)){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
let _agentOutput='(no output)';
try{
_agentOutput=typeof executeAgentCommand==='function'
? await executeAgentCommand(text,_agentCmd||{name:_agentCmdName})
: 'Agent command runtime unavailable in WebUI.';
}catch(e){
_agentOutput=`Agent command error: ${e&&e.message||e}`;
}
S.messages.push({role:'assistant',content:String(_agentOutput||'(no output)'),_ts:Date.now()/1000});
renderMessages();
$('msg').value='';autoResize();hideCmdDropdown();return;
}
if(_agentCmd&&_agentCmd.category==='Plugin'){
if(!S.session){await newSession();await renderSessionList();}
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
@@ -1804,6 +1913,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof noteWorkspaceMutationsFromToolCall==='function') noteWorkspaceMutationsFromToolCall(tc);
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
_maybeNotifyPersistentStateSaved(tc);
if(typeof refreshOpenPreviewIfMutated==='function') refreshOpenPreviewIfMutated();
appendLiveToolCard(tc);
snapshotLiveTurn();
@@ -1824,6 +1934,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed');
});
source.addEventListener('state_saved',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
if(!S.session||S.session.session_id!==activeSid) return;
_showPersistentStateToast(d.kind, d.name||'', {created:String(d.action||'').toLowerCase()==='created'});
});
source.addEventListener('title',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
@@ -2422,7 +2540,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_setActivePaneIdleIfOwner();
});
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','state_saved','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
}
}

View File

@@ -430,17 +430,34 @@ function _cronDiagnostics(job) {
return JSON.stringify(fields, null, 2);
}
function _gatewayStatusReason(status) {
const health = status && typeof status.health === 'object' ? status.health : null;
if (!health) return '';
return typeof health.reason === 'string' ? health.reason.trim() : '';
}
function _cronGatewayNoticeHtml(status) {
if (!status || (status.configured && status.running)) return '';
const reason = _gatewayStatusReason(status);
const isStaleMetadata = reason === 'gateway_stale_running_state';
const isRemoteUnreachable = reason === 'remote_gateway_unreachable';
const notConfigured = !status.configured;
const title = notConfigured
? 'Gateway not configured'
: 'Gateway not running';
: isStaleMetadata
? 'Gateway metadata stale'
: isRemoteUnreachable
? 'Gateway endpoint not reachable'
: 'Gateway not running';
const body = notConfigured
? 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon. If this is a single-container Docker install, jobs can be created and run manually here, but scheduled ticks need a gateway container or `hermes gateway` running outside the WebUI.'
: 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon to be running. Start the gateway container or `hermes gateway` before relying on offline scheduled runs.';
: isStaleMetadata
? 'The gateway is marked as configured, but its health metadata has gone stale. In Docker, scheduled jobs require a live gateway daemon that refreshes runtime metadata while ticking cron.'
: isRemoteUnreachable
? 'The gateway health endpoint is not reachable from WebUI. Verify the configured gateway URL env var (`GATEWAY_HEALTH_URL`, `HERMES_GATEWAY_HEALTH_URL`, `HERMES_API_URL`, or `HERMES_WEBUI_GATEWAY_BASE_URL`) points to a reachable gateway service and network path before relying on cron ticking.'
: 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon to be running. Start the gateway container or `hermes gateway` before relying on offline scheduled runs.';
const docsHref = 'https://github.com/nesquena/hermes-webui/blob/master/docs/docker.md#scheduled-jobs-and-the-gateway-daemon';
const helpLink = notConfigured
const helpLink = notConfigured || isRemoteUnreachable || isStaleMetadata
? `<p><a href="${docsHref}" target="_blank" rel="noopener">How to enable scheduled jobs in Docker ↗</a></p>`
: '';
return `
@@ -8080,7 +8097,13 @@ function loadGatewayStatus(){
return;
}
if(!r.running){
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>Gateway not running</div>`;
const reason = _gatewayStatusReason(r);
const statusLabel = reason === 'gateway_stale_running_state'
? 'Gateway metadata stale'
: reason === 'remote_gateway_unreachable'
? 'Gateway endpoint not reachable'
: 'Gateway not running';
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>${esc(statusLabel)}</div>`;
return;
}
const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'};

View File

@@ -4528,10 +4528,132 @@ function _stripForTTS(text){
return text;
}
function _splitForTTS(text, maxChars){
// Split long text into chunks at natural sentence/paragraph boundaries
// to avoid browser SpeechSynthesis truncation on long texts.
maxChars=maxChars||300;
if(text.length<=maxChars) return [text];
const chunks=[];
let remaining=text;
while(remaining.length>0){
if(remaining.length<=maxChars){ chunks.push(remaining); break; }
let splitAt=maxChars;
const sentencePattern=new RegExp('^[\\s\\S]{0,'+(maxChars-1)+'}[。!?.!](?=\\s|$)','g');
const m=sentencePattern.exec(remaining);
if(m) splitAt=m.index+m[0].length;
else{
const sub=remaining.slice(0,maxChars);
const lastSpace=Math.max(sub.lastIndexOf(' '),sub.lastIndexOf('\n'),sub.lastIndexOf(','),sub.lastIndexOf(''));
if(lastSpace>maxChars*0.5) splitAt=lastSpace+1;
}
chunks.push(remaining.slice(0,splitAt).trim());
remaining=remaining.slice(splitAt).trim();
}
return chunks.filter(Boolean);
}
let _ttsSpeaking=false;
let _ttsCurrentUtterance=null;
let _ttsChunkQueue=[];
let _ttsChunkIndex=0;
let _ttsActiveBtn=null;
let _playingEdgeAudio=null;
function _buildBrowserUtterance(text, btn){
const utter=new SpeechSynthesisUtterance(text);
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
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);
}
function speakMessage(btn){
if(btn&&btn.dataset.speaking==='1'){
stopTTS();
@@ -4548,7 +4670,7 @@ function speakMessage(btn){
const engine=localStorage.getItem('hermes-tts-engine')||'browser';
if(engine==='edge'){
_playEdgeTts(clean, btn);
_playEdgeTtsChunked(clean, btn);
return;
}
@@ -4557,76 +4679,17 @@ function speakMessage(btn){
return;
}
const utter=new SpeechSynthesisUtterance(clean);
// Apply saved voice preference
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
// Apply saved rate/pitch
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
if(!isNaN(savedRate)) utter.rate= Math.min(2,Math.max(0.5,savedRate));
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
_ttsChunkQueue=_splitForTTS(clean);
_ttsChunkIndex=0;
_ttsActiveBtn=btn;
_ttsSpeaking=true;
if(btn) btn.dataset.speaking='1';
const utter=_buildBrowserUtterance(_ttsChunkQueue[0], btn);
_ttsCurrentUtterance=utter;
_ttsSpeaking=true;
if(btn) btn.dataset.speaking='1';
utter.onend=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; if(btn) btn.dataset.speaking='0'; };
utter.onerror=()=>{ _ttsSpeaking=false; _ttsCurrentUtterance=null; if(btn) btn.dataset.speaking='0'; };
speechSynthesis.speak(utter);
}
function _playEdgeTts(text, btn){
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';}
if(btn) btn.dataset.speaking='1';
_ttsSpeaking=true;
// /api/tts is POST-only (and behind the same-origin CSRF gate); GET via
// new Audio(url) would 405 and silently fail, and would also leak the message
// text into the query string / access log. POST the JSON body, then play the
// returned audio via an object URL — mirrors the working boot.js edge path.
const _fail=function(msg){
_ttsSpeaking=false;_playingEdgeAudio=null;
if(btn)btn.dataset.speaking='0';
if(msg&&typeof showToast==='function') showToast(msg,4000,'error');
};
fetch(new URL('api/tts', document.baseURI || location.href).href, {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:text, voice:voice, rate:rate, pitch:pitch})
})
.then(function(r){
if(!r.ok){
// Surface the server error (e.g. 503 "edge-tts not installed", 429 rate limit)
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){
const url=URL.createObjectURL(blob);
const audio=new Audio(url);
_playingEdgeAudio=audio;
const _cleanup=function(){_ttsSpeaking=false;_playingEdgeAudio=null;if(btn)btn.dataset.speaking='0';try{URL.revokeObjectURL(url);}catch(_){}};
audio.onended=_cleanup;
audio.onerror=function(){_cleanup();};
audio.play().catch(function(e){_cleanup();showToast('Edge TTS error: '+(e&&e.message||e));});
})
.catch(function(e){ _fail((e&&e.message)||'Edge TTS failed'); });
}
function stopTTS(){
if('speechSynthesis' in window){
speechSynthesis.cancel();
@@ -4638,6 +4701,9 @@ function stopTTS(){
}
_ttsSpeaking=false;
_ttsCurrentUtterance=null;
_ttsChunkQueue=[];
_ttsChunkIndex=0;
_ttsActiveBtn=null;
// Reset all speaking buttons
document.querySelectorAll('[data-speaking="1"]').forEach(btn=>{ btn.dataset.speaking='0'; });
}
@@ -4656,21 +4722,15 @@ function autoReadLastAssistant(){
const clean=_stripForTTS(text);
if(!clean) return;
if(engine==='edge'){
_playEdgeTts(clean, null);
_playEdgeTtsChunked(clean, null);
return;
}
const utter=new SpeechSynthesisUtterance(clean);
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
// Use chunked playback for browser TTS
_ttsChunkQueue=_splitForTTS(clean);
_ttsChunkIndex=0;
_ttsSpeaking=true;
const utter=_buildBrowserUtterance(_ttsChunkQueue[0], null);
_ttsCurrentUtterance=utter;
speechSynthesis.speak(utter);
}

View File

@@ -200,6 +200,26 @@ def test_gateway_health_url_with_health_suffix_is_normalized(monkeypatch):
assert payload["details"].get("gateway_state") == "running"
def test_gateway_webui_base_url_env_is_used_for_remote_probe(monkeypatch):
"""`HERMES_WEBUI_GATEWAY_BASE_URL` should be treated like a gateway health base URL."""
monkeypatch.delenv("HERMES_API_URL", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_HEALTH_URL", raising=False)
monkeypatch.delenv("GATEWAY_HEALTH_URL", raising=False)
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://container-gw:8642")
seen = []
def fake_urlopen(req, timeout=None):
seen.append(req.full_url)
return _FakeResp(200, body=b'{"gateway_state":"running"}')
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is True
assert payload["details"]["reason"] == "remote_gateway"
assert seen[0].startswith("http://container-gw:8642/")
def test_oversized_remote_body_does_not_hang_and_skips_parse(monkeypatch):
"""A 2xx response with a huge body must be capped (not read unbounded) and
still report the gateway alive, just without a parsed gateway_state."""

192
tests/test_auth.py Normal file
View File

@@ -0,0 +1,192 @@
"""Unit tests for cookie security hardening (Issue #1909, Slice 3).
Covers:
- SameSite=Lax on the auth cookie
- _is_loopback() helper
- _is_secure_context() priority logic:
1. HERMES_WEBUI_SECURE override
2. Direct TLS (getpeercert)
3. HERMES_WEBUI_TRUST_FORWARDED_PROTO opt-in for X-Forwarded-Proto
4. Otherwise -> not Secure (plain HTTP, regardless of client address)
"""
import http.cookies
import io
from api.auth import _is_loopback, _is_secure_context, set_auth_cookie, COOKIE_NAME
# ── Mock handler helpers ─────────────────────────────────────────────────────
class _MockRequest:
"""Fake socket-like request object."""
def __init__(self, *, has_peercert: bool = False):
if has_peercert:
self.getpeercert = lambda: {'subject': ()}
class _MockHandler:
"""Minimal BaseHTTPRequestHandler stand-in."""
def __init__(
self,
client_address=('127.0.0.1', 12345),
headers=None,
request=None,
):
self.client_address = client_address
self.headers = headers or {}
self.request = request
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.rfile = io.BytesIO(b'')
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def _set_cookie_header(self):
for name, val in self.sent_headers:
if name == 'Set-Cookie':
return val
return ''
# ── _is_loopback tests ───────────────────────────────────────────────────────
def test_is_loopback_127_0_0_1():
assert _is_loopback('127.0.0.1') is True
def test_is_loopback_127_255_255_255():
assert _is_loopback('127.255.255.255') is True
def test_is_loopback_ipv6_loopback():
assert _is_loopback('::1') is True
def test_is_loopback_ipv4_mapped_ipv6_loopback():
assert _is_loopback('::ffff:127.0.0.1') is True
def test_is_loopback_private_not_loopback():
assert _is_loopback('10.0.0.1') is False
def test_is_loopback_rfc1918_not_loopback():
assert _is_loopback('192.168.1.1') is False
# ── samesite=Lax tests ──────────────────────────────────────────────────────
def test_samesite_lax_in_cookie(monkeypatch):
"""set_auth_cookie must emit SameSite=Lax."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
handler = _MockHandler()
set_auth_cookie(handler, 'test-token-value')
cookie_header = handler._set_cookie_header()
assert cookie_header, "Set-Cookie header was not sent"
# Parse via http.cookies to be case/whitespace independent
c = http.cookies.SimpleCookie()
c.load(cookie_header)
assert COOKIE_NAME in c
assert c[COOKIE_NAME]['samesite'].lower() == 'lax'
# ── _is_secure_context tests ─────────────────────────────────────────────────
def test_secure_not_set_for_loopback(monkeypatch):
"""Loopback client with no TLS and no env vars → not secure."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
handler = _MockHandler(client_address=('127.0.0.1', 9999))
assert _is_secure_context(handler) is False
def test_plain_http_non_loopback_not_secure(monkeypatch):
"""Plain HTTP from a LAN IP must NOT set Secure. Regression test for PR #3562."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
handler = _MockHandler(client_address=('10.0.0.1', 9999))
assert _is_secure_context(handler) is False
def test_plain_http_rfc1918_class_a_not_secure(monkeypatch):
"""192.168.x.x over plain HTTP must not be secure."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
handler = _MockHandler(client_address=('192.168.1.50', 9999))
assert _is_secure_context(handler) is False
def test_plain_http_tailscale_not_secure(monkeypatch):
"""Tailscale CGNAT range (100.64.x.x) over plain HTTP must not be secure."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
handler = _MockHandler(client_address=('100.64.0.1', 9999))
assert _is_secure_context(handler) is False
def test_trust_forwarded_proto_opt_in(monkeypatch):
"""With opt-in env var set and X-Forwarded-Proto: https → secure."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.setenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', '1')
handler = _MockHandler(
client_address=('127.0.0.1', 9999),
headers={'X-Forwarded-Proto': 'https'},
)
assert _is_secure_context(handler) is True
def test_forwarded_proto_ignored_without_opt_in(monkeypatch):
"""Without opt-in, X-Forwarded-Proto: https on loopback is ignored → not secure."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
handler = _MockHandler(
client_address=('127.0.0.1', 9999),
headers={'X-Forwarded-Proto': 'https'},
)
assert _is_secure_context(handler) is False
def test_hermes_webui_secure_override_on(monkeypatch):
"""HERMES_WEBUI_SECURE=1 forces secure True regardless of other conditions."""
monkeypatch.setenv('HERMES_WEBUI_SECURE', '1')
# Loopback, no TLS, no forwarded-proto opt-in; override must win
handler = _MockHandler(client_address=('127.0.0.1', 9999))
assert _is_secure_context(handler) is True
def test_hermes_webui_secure_override_off(monkeypatch):
"""HERMES_WEBUI_SECURE=0 forces secure False regardless of other conditions."""
monkeypatch.setenv('HERMES_WEBUI_SECURE', '0')
# Explicit override must win even for non-loopback addresses
handler = _MockHandler(client_address=('10.0.0.1', 9999))
assert _is_secure_context(handler) is False
def test_direct_tls_socket_is_secure(monkeypatch):
"""Direct TLS socket (getpeercert present) → secure, regardless of address."""
monkeypatch.delenv('HERMES_WEBUI_SECURE', raising=False)
monkeypatch.delenv('HERMES_WEBUI_TRUST_FORWARDED_PROTO', raising=False)
tls_request = _MockRequest(has_peercert=True)
handler = _MockHandler(
client_address=('127.0.0.1', 9999),
request=tls_request,
)
assert _is_secure_context(handler) is True

View File

@@ -59,6 +59,14 @@ def test_frontend_matches_agent_command_aliases():
assert "some(a=>String(a||'').toLowerCase()===needle)" in helper
def test_frontend_can_execute_agent_commands_via_api_endpoint():
assert "async function executeAgentCommand" in COMMANDS_JS
assert "async function executeAgentPluginCommand" in COMMANDS_JS
assert "async function _runAgentCommandTransport" in COMMANDS_JS
assert "api('/api/commands/exec'" in COMMANDS_JS
assert COMMANDS_JS.count("api('/api/commands/exec'") == 1
def test_cli_only_response_mentions_webui_and_cli_scope():
assert "function cliOnlyCommandResponse" in COMMANDS_JS
assert "Hermes CLI-only command" in COMMANDS_JS
@@ -169,6 +177,23 @@ def test_send_intercepts_cli_only_commands_before_agent_round_trip():
assert "return;" in intercept
def test_send_intercepts_reload_mcp_agent_command_before_agent_round_trip():
intercept_idx = MESSAGES_JS.find("Slash command intercept")
normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx)
assert normal_send_idx != -1
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
assert "const _agentCmdName=String(_agentCmd&&_agentCmd.name||_parsedCmd&&_parsedCmd.name||'')" in intercept
assert "if(_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" in intercept
assert "executeAgentCommand(text,_agentCmd||{name:_agentCmdName})" in intercept
def test_reload_mcp_webui_intercept_aliases_are_defined_in_js_whitelist():
assert "'reload-mcp'" in MESSAGES_JS
assert "'reload_mcp'" in MESSAGES_JS
assert "if(_agentCmd&&_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" not in MESSAGES_JS
def test_unknown_slash_commands_still_fall_through_to_agent():
"""Only explicitly supported metadata-backed commands should be intercepted."""
intercept_idx = MESSAGES_JS.find("Slash command intercept")
@@ -176,6 +201,7 @@ def test_unknown_slash_commands_still_fall_through_to_agent():
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept
assert "if(_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" in intercept
assert "if(_agentCmd&&_agentCmd.category==='Plugin')" in intercept
assert "if(_parsedCmd&&!_cmd)" in intercept
assert "if(!_agentCmd" not in intercept

View File

@@ -1,18 +1,54 @@
"""Tests for GET /api/commands -- exposes hermes-agent COMMAND_REGISTRY."""
import json
import urllib.error
import urllib.request
import threading
import time
from types import ModuleType
import pytest
from tests.conftest import TEST_BASE, requires_agent_modules
def _install_fake_mcp_tool(monkeypatch, shutdown, discover, servers=None, lock=None):
import sys
tools_pkg = ModuleType("tools")
tools_pkg.__path__ = []
mcp_tool = ModuleType("tools.mcp_tool")
mcp_tool.shutdown_mcp_servers = shutdown
mcp_tool.discover_mcp_tools = discover
mcp_tool._servers = servers if servers is not None else {}
mcp_tool._lock = lock if lock is not None else threading.Lock()
monkeypatch.setitem(sys.modules, "tools", tools_pkg)
monkeypatch.setitem(sys.modules, "tools.mcp_tool", mcp_tool)
return mcp_tool
def _get(path):
"""GET helper -- returns parsed JSON or raises HTTPError."""
with urllib.request.urlopen(TEST_BASE + path, timeout=10) as r:
return json.loads(r.read())
def _post(path, body):
payload = json.dumps(body or {}).encode()
req = urllib.request.Request(
TEST_BASE + path,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return getattr(r, 'status', 200), json.loads(r.read())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read())
except Exception:
return e.code, {}
@requires_agent_modules
def test_commands_endpoint_returns_list():
"""GET /api/commands returns a JSON object with a 'commands' list."""
@@ -64,6 +100,139 @@ def test_commands_endpoint_keeps_new_with_reset_alias():
assert 'reset' in new_cmd['aliases']
@requires_agent_modules
def test_commands_exec_runs_allowlisted_agent_command():
"""Allowed agent-side commands execute through /api/commands/exec."""
status, body = _post('/api/commands/exec', {'command': '/reload-mcp'})
assert status == 200
assert 'output' in body
assert isinstance(body['output'], str)
@requires_agent_modules
def test_commands_exec_runs_reload_mcp_alias():
"""Telegram-style underscore alias resolves to the same allowlisted command."""
status, body = _post('/api/commands/exec', {'command': '/reload_mcp'})
assert status == 200
assert 'output' in body
assert isinstance(body['output'], str)
def test_reload_mcp_error_is_generic(monkeypatch):
"""`/reload-mcp` errors must return a generic message, not raw internals."""
calls = []
def shutdown():
calls.append("shutdown")
raise RuntimeError("db_dsn=postgresql://user:pass@localhost/secret")
def discover():
calls.append("discover")
return []
_install_fake_mcp_tool(
monkeypatch,
shutdown=shutdown,
discover=discover,
servers={"old": object()},
)
from api.commands import execute_agent_command
with pytest.raises(RuntimeError) as exc:
execute_agent_command('/reload-mcp')
assert str(exc.value) == "Failed to reload MCP servers"
assert 'postgresql://user:pass' not in str(exc.value)
assert 'pass@' not in str(exc.value)
assert calls == ["shutdown"]
def test_concurrent_reload_mcp_calls_are_serialized(monkeypatch):
"""Concurrent `/reload-mcp` calls cannot run shutdown/discover interleaved."""
state = {"active": 0, "max_active": 0}
lock = threading.Lock()
ready = threading.Event()
def _track():
with lock:
state["active"] += 1
if state["active"] > state["max_active"]:
state["max_active"] = state["active"]
time.sleep(0.12)
with lock:
state["active"] -= 1
def shutdown():
ready.set()
_track()
def discover():
_track()
return ["tool-a", "tool-b"]
_install_fake_mcp_tool(
monkeypatch,
shutdown=shutdown,
discover=discover,
servers={"old": object()},
lock=threading.Lock(),
)
from api.commands import execute_agent_command
errors = []
t2_started = threading.Event()
def _call():
try:
execute_agent_command('/reload-mcp')
except Exception as exc:
errors.append(exc)
def _call2():
t2_started.set()
try:
execute_agent_command('/reload-mcp')
except Exception as exc:
errors.append(exc)
t1 = threading.Thread(target=_call, name="reload-1")
t2 = threading.Thread(target=_call2, name="reload-2")
t1.start()
assert ready.wait(1), "first reload did not start"
t2.start()
assert t2_started.wait(1), "second reload did not start"
time.sleep(0.05)
with lock:
observed_max = state["max_active"]
assert observed_max == 1
t1.join(timeout=5)
t2.join(timeout=5)
assert not t1.is_alive() and not t2.is_alive()
assert not errors
@requires_agent_modules
def test_commands_exec_cli_only_command_returns_404():
"""CLI-only commands should stay blocked from the generic execution endpoint."""
status, body = _post('/api/commands/exec', {'command': '/clear'})
assert status == 404
assert isinstance(body, dict)
@requires_agent_modules
def test_commands_exec_regular_agent_command_returns_404():
"""Non-allowlisted agent commands must not become generic WebUI exec targets."""
status, body = _post('/api/commands/exec', {'command': '/help'})
assert status == 404
assert isinstance(body, dict)
def test_list_commands_returns_empty_for_empty_registry():
"""list_commands(_registry=[]) returns [] -- the same path as when
hermes_cli is missing (the empty-or-missing case)."""

View File

@@ -48,7 +48,7 @@ class _FakeHandler:
# ── Helpers ──────────────────────────────────────────────────────────────────
def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None):
def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None, details=None):
"""Invoke handle_get for /api/gateway/status and return the parsed JSON.
monkeypatches build_agent_health_payload to return the given `alive` value
@@ -62,7 +62,7 @@ def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None):
lambda: {
"alive": agent_health_alive,
"checked_at": "2026-05-06T12:00:00+00:00",
"details": {},
"details": details or {},
},
)
@@ -236,6 +236,25 @@ def test_gateway_status_missing_r_field_handled_by_frontend(monkeypatch):
assert "configured" in result
def test_gateway_status_includes_gateway_health_metadata(monkeypatch):
"""Expose gateway health reason/state metadata so the UI can render better diagnostics."""
result = _call_gateway_status(
monkeypatch,
agent_health_alive=None,
identity_map={},
details={
"state": "unknown",
"reason": "gateway_stale_running_state",
"gateway_state": "running",
},
)
assert result["health"] == {
"state": "unknown",
"reason": "gateway_stale_running_state",
"gateway_state": "running",
}
def test_gateway_status_last_active_empty_when_alive_and_no_sessions_path(monkeypatch):
"""Bonus: alive=true + identity_map={} → last_active is empty string.
This guards the 'if running and sessions_path.exists()' guard from being

View File

@@ -24,6 +24,9 @@ def test_cron_panel_loads_gateway_status_for_scheduling_guidance():
assert "api('/api/gateway/status')" in panels
assert "Gateway not configured" in panels
assert "Gateway not running" in panels
assert "Gateway endpoint not reachable" in panels
assert "configured gateway URL env var" in panels
assert "GATEWAY_HEALTH_URL" in panels
assert "scheduled jobs require the Hermes gateway daemon" in panels
assert "loadCronGatewayNotice()" in panels
@@ -34,4 +37,8 @@ def test_docker_docs_explain_single_container_cron_gateway_boundary():
assert "single-container setup runs the WebUI only" in docs
assert "scheduled jobs require the Hermes gateway daemon" in docs
assert "Gateway not configured" in docs
assert "Gateway metadata stale" in docs
assert "Gateway endpoint not reachable" in docs
assert "`gateway_state.json` can become stale" in docs
assert "HERMES_WEBUI_GATEWAY_BASE_URL" in docs
assert "docker-compose.two-container.yml" in docs

View File

@@ -0,0 +1,74 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
STREAMING_PY = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8")
CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
def _tool_complete_listener_block() -> str:
start = MESSAGES_JS.index("source.addEventListener('tool_complete'")
end = MESSAGES_JS.index("source.addEventListener('approval'", start)
return MESSAGES_JS[start:end]
def test_tool_complete_notifies_on_persistent_state_writes():
assert "function _maybeNotifyPersistentStateSaved(tool)" in MESSAGES_JS
block = _tool_complete_listener_block()
assert "_maybeNotifyPersistentStateSaved(tc);" in block
assert block.index("tc.is_error=!!d.is_error;") < block.index("_maybeNotifyPersistentStateSaved(tc);")
assert block.index("if(!S.session||S.session.session_id!==activeSid) return;") < block.index("_maybeNotifyPersistentStateSaved(tc);")
assert block.index("_maybeNotifyPersistentStateSaved(tc);") < block.index("refreshOpenPreviewIfMutated")
def test_persistent_state_toast_classifier_is_write_only_and_deduped():
helper_start = MESSAGES_JS.index("function _persistentToastHasWriteIntent")
helper_end = MESSAGES_JS.index("function _persistentToastSkillName", helper_start)
helper = MESSAGES_JS[helper_start:helper_end]
assert "read|list|view|search|lookup|get|fetch|load|usage|toggle|delete|remove" in helper
assert "save|saved|write|wrote|written|update|updated|create|created|store|stored|persist|persisted|remember|remembered" in helper
assert "_persistentStateToastSeen.has(dedupeKey)" in MESSAGES_JS
assert "_persistentStateToastSeen.add(dedupeKey)" in MESSAGES_JS
assert "_showPersistentStateToast(isSkill?'skill':'memory'" in MESSAGES_JS
assert "if(isSkill&&!skillName)return;" in MESSAGES_JS
def test_persistent_state_toasts_use_existing_user_visible_labels():
notify_start = MESSAGES_JS.index("function _maybeNotifyPersistentStateSaved")
notify_end = MESSAGES_JS.index("function _selectedTextReplyT", notify_start)
notify = MESSAGES_JS[notify_start:notify_end]
assert "t('memory_saved')" in notify
assert "t('skill_created')" in notify
assert "t('skill_updated')" in notify
assert "showToast(itemName?`${base}: ${itemName}`:base,4200,'success')" in notify
assert "showToast(t('memory_saved'),3600,'success')" in notify
def test_backend_emits_state_saved_sse_from_file_snapshots():
assert "def _persistent_state_snapshot" in STREAMING_PY
assert "def _persistent_state_changes" in STREAMING_PY
assert '_persistent_state_before = _persistent_state_snapshot(_profile_home)' in STREAMING_PY
assert 'put("state_saved", {' in STREAMING_PY
assert '"kind": "memory"' in STREAMING_PY
assert '"kind": "skill"' in STREAMING_PY
def test_frontend_handles_state_saved_sse_and_reuses_dedupe():
start = MESSAGES_JS.index("source.addEventListener('state_saved'")
end = MESSAGES_JS.index("source.addEventListener('title'", start)
block = MESSAGES_JS[start:end]
assert "_showPersistentStateToast(d.kind, d.name||''" in block
assert "String(d.action||'').toLowerCase()==='created'" in block
assert "if((d.session_id||activeSid)!==activeSid) return;" in block
assert "'state_saved'" in MESSAGES_JS
def test_issue_3340_changelog_entry_present():
assert "#3340" in CHANGELOG
assert "saved memory" in CHANGELOG
assert "created/updated a skill" in CHANGELOG

View File

@@ -0,0 +1,110 @@
"""Tests for graceful stash-pop failure recovery in _apply_update_inner."""
from unittest.mock import patch
import api.updates as updates
def test_stash_pop_conflict_preserves_stash(tmp_path):
"""On stash-pop failure, stash is preserved and no restart is scheduled."""
call_log = []
def fake_git(args, path, timeout=10):
call_log.append(args)
if args[:2] == ['fetch', 'origin']:
return '', True
if args == ['status', '--porcelain', '--untracked-files=no']:
return 'M modified_file.py', True
if args == ['stash']:
return '', True
if args[:2] == ['pull', '--ff-only']:
return 'Already up to date.', True
if args == ['stash', 'pop']:
return 'CONFLICT (content): Merge conflict in modified_file.py', False
if args == ['reset', '--merge']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
restart_calls = []
with (
patch.object(updates, '_run_git', side_effect=fake_git),
patch.object(updates, '_select_apply_compare_ref', return_value='origin/master'),
patch.object(updates, '_schedule_restart', side_effect=lambda: restart_calls.append(1)),
):
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert result['stash_conflict'] is True
assert 'stash@{0}' in result['message']
assert ['stash', 'drop'] not in call_log
assert ['reset', '--merge'] in call_log
assert len(restart_calls) == 0
def test_stash_pop_reset_failure_returns_error(tmp_path):
"""If reset --merge also fails, return ok=False so the app does not restart into a broken tree."""
call_log = []
def fake_git(args, path, timeout=10):
call_log.append(args)
if args[:2] == ['fetch', 'origin']:
return '', True
if args == ['status', '--porcelain', '--untracked-files=no']:
return 'M modified_file.py', True
if args == ['stash']:
return '', True
if args[:2] == ['pull', '--ff-only']:
return 'Already up to date.', True
if args == ['stash', 'pop']:
return 'CONFLICT', False
if args == ['reset', '--merge']:
return 'error: could not reset', False
raise AssertionError(f'unexpected git args: {args!r}')
restart_calls = []
with (
patch.object(updates, '_run_git', side_effect=fake_git),
patch.object(updates, '_select_apply_compare_ref', return_value='origin/master'),
patch.object(updates, '_schedule_restart', side_effect=lambda: restart_calls.append(1)),
):
result = updates._apply_update_inner('webui')
assert result['ok'] is False
assert result['stash_conflict'] is True
assert 'Manual intervention' in result['message']
assert 'stash drop' not in result['message']
assert len(restart_calls) == 0
assert ['stash', 'drop'] not in call_log
def test_stash_pop_success_still_restarts(tmp_path):
"""Happy path: stash pop succeeds, restart is scheduled."""
call_log = []
def fake_git(args, path, timeout=10):
call_log.append(args)
if args[:2] == ['fetch', 'origin']:
return '', True
if args == ['status', '--porcelain', '--untracked-files=no']:
return 'M modified_file.py', True
if args == ['stash']:
return '', True
if args[:2] == ['pull', '--ff-only']:
return 'Already up to date.', True
if args == ['stash', 'pop']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
restart_calls = []
with (
patch.object(updates, '_run_git', side_effect=fake_git),
patch.object(updates, '_select_apply_compare_ref', return_value='origin/master'),
patch.object(updates, '_schedule_restart', side_effect=lambda: restart_calls.append(1)),
):
result = updates._apply_update_inner('webui')
assert result['ok'] is True
assert 'stash_conflict' not in result
assert len(restart_calls) == 1