Release v0.51.260 — Release IB (stage-r8) (#3614)
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
41
api/auth.py
41
api/auth.py
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:'🔌'};
|
||||
|
||||
218
static/ui.js
218
static/ui.js
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
192
tests/test_auth.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
74
tests/test_issue3340_persistent_state_toasts.py
Normal file
74
tests/test_issue3340_persistent_state_toasts.py
Normal 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
|
||||
110
tests/test_update_stash_recovery.py
Normal file
110
tests/test_update_stash_recovery.py
Normal 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
|
||||
Reference in New Issue
Block a user