Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@

## [Unreleased]

## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics)

### Fixed

- **PR #2839** by @tn801534 — Kanban worker log endpoint constructed URLs with a double query string (`?board=<slug>?tail=65536`) when a non-default board was active. The frontend was appending `?tail=65536` directly to a URL that already had `?board=...` from `_kanbanBoardQuery()`. Fix: pass `{tail: 65536}` as the `extra` argument to `_kanbanBoardQuery()` so it composes both params into a single valid query string. One-line, narrow scope.

- **PR #2832** by @franksong2702 — Malformed HTTP request logging in `server.py` falls back to `"-"` for missing `command` or `path` instead of raising `AttributeError`. Defensive `getattr(self, 'command', None) or '-'` matches the pattern already used for `_req_t0` elsewhere in the handler. Adds `tests/test_issue2775_log_request.py` covering the malformed-request-before-path-assigned case.

- **PR #2818** by @humayunak — Approval and clarify cards no longer steal focus from the composer textarea (`#msg`) when the user is mid-type. `showApprovalCard()` and `showClarifyCard()` now guard the `focus()` call on `document.activeElement !== $('msg')`, matching the pattern already used elsewhere for focus-sensitive paths. The clarify card also moves the focus call out of `setTimeout` for snappier UX. Silently dropped keystrokes during streaming are eliminated.

- **PR #2826** by @Koraji95-coder — Composer footer chip wraps no longer overlap at narrow widths (closes #2740). The five chip wraps (`.composer-profile-wrap`, `.composer-ws-wrap`, `.composer-model-wrap`, `.composer-reasoning-wrap`, `.composer-toolsets-wrap`) had `flex: 0 1 auto` + `min-width: 0` so they would compress past their content's natural width when the composer narrowed, causing visual overlap of the profile / workspace / model / reasoning chips. Switched to `flex: 0 0 auto` via a single grouped selector. Each chip now keeps its natural width and the existing `overflow-x: auto` on `.composer-left` handles overflow via horizontal scroll. Default-width layout unchanged; only affects the overflow regime. Mobile-specific rules (already `flex: 0 0 auto`) untouched.

- **PR #2829** by @franksong2702 — Workspace Markdown previews fall back to plain text for very large files (>64 KB or >1500 lines) instead of synchronously running the full rich Markdown renderer on the browser main thread, which could lock up the tab for several seconds on multi-megabyte `.md` files. Plain-text preview shows file size + line count in the status line so users know why rich rendering was bypassed; Edit mode still shows raw content as before. Closes #2823. Supersedes #2828 (same scope, less polished).

- **PR #2837** by @franksong2702 — CSRF rejections now distinguish origin/proxy mismatches from expired session tokens, so provider-key removal and other protected requests show actionable diagnostics instead of the generic "Cross-origin request rejected" error. Adds `tests/test_issue2572_csrf_diagnostics.py` covering both failure modes.

- **PR #2834** by @franksong2702 — Workspace Markdown `mailto:` and `tel:` links now render as clickable links, and sandboxed HTML preview links open outside the iframe (via injected `<base target="_blank">`) instead of navigating the preview into a browser-blocked page. Adds `tests/test_issue2768_workspace_links.py`.

- **PR #2838** by @franksong2702 — Tasks panel surfaces a warning when the Hermes gateway is not configured or not running, so Docker users know scheduled jobs need the gateway daemon to tick while away. The single-container Docker boundary is also clarified in `docs/docker.md`. Adds `tests/test_issue2785_gateway_cron_guidance.py`.

### Added

- **PR #2820** by @tangerine-fan — Clarify user choice is now echoed as a visible message in the conversation transcript. After the user responds to a clarify prompt, a synthetic user message with the chosen value is inserted into `S.messages` (marked `_clarify_response: true` so downstream consumers can filter if needed). Previously the choice was only visible in the transient clarify card; now the chat history preserves the decision.

- **PR #2843** by @AJV20 — New Settings preference "Ignore Agent updates" keeps WebUI update notices, banners, and update actions enabled while suppressing Hermes Agent update checks. Default `False` (current behavior). Useful when running an unreleased agent build or pinning to a specific agent commit.

## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening)

### Added
Expand Down
2 changes: 2 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4365,6 +4365,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
"ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
"theme": "dark", # light | dark | system
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous
Expand Down Expand Up @@ -4524,6 +4525,7 @@ def load_settings() -> dict:
"show_previous_messaging_sessions",
"sync_to_insights",
"check_for_updates",
"ignore_agent_updates",
"whats_new_summary_enabled",
"sound_enabled",
"rtl",
Expand Down
98 changes: 89 additions & 9 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1260,8 +1260,37 @@ def _csrf_exempt_path(path: str) -> bool:
return path in {"/api/auth/login", "/api/csp-report"}


_CSRF_FAILURE_ATTR = "_hermes_csrf_failure_reason"


def _set_csrf_failure_reason(handler, reason: str) -> bool:
try:
setattr(handler, _CSRF_FAILURE_ATTR, reason)
except Exception:
pass
return False


def _clear_csrf_failure_reason(handler) -> None:
try:
if hasattr(handler, _CSRF_FAILURE_ATTR):
delattr(handler, _CSRF_FAILURE_ATTR)
except Exception:
pass


def _csrf_rejection_error(handler) -> str:
reason = getattr(handler, _CSRF_FAILURE_ATTR, "")
if reason == "origin_mismatch":
return "Cross-origin mismatch - check reverse proxy headers"
if reason == "token_mismatch":
return "Session expired - reload the page"
return "Cross-origin request rejected"


def _check_csrf(handler) -> bool:
"""Reject cross-origin or tokenless authenticated browser unsafe requests."""
_clear_csrf_failure_reason(handler)
origin = handler.headers.get("Origin", "")
referer = handler.headers.get("Referer", "")
host = handler.headers.get("Host", "")
Expand All @@ -1271,7 +1300,7 @@ def _check_csrf(handler) -> bool:
# Extract host:port from origin/referer
m = _re.match(r"^https?://([^/]+)", target)
if not m:
return False
return _set_csrf_failure_reason(handler, "origin_mismatch")
origin_host = m.group(1)
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
origin_name, origin_port = _normalize_host_port(origin_host)
Expand Down Expand Up @@ -1299,15 +1328,17 @@ def _check_csrf(handler) -> bool:
origin_allowed = True
break
if not origin_allowed:
return False
return _set_csrf_failure_reason(handler, "origin_mismatch")

from api.auth import CSRF_HEADER_NAME, is_auth_enabled, parse_cookie, verify_csrf_token

if not is_auth_enabled():
return True
cookie_val = parse_cookie(handler)
submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token")
return verify_csrf_token(cookie_val or "", submitted or "")
if verify_csrf_token(cookie_val or "", submitted or ""):
return True
return _set_csrf_failure_reason(handler, "token_mismatch")


def _client_ip_for_rate_limit(handler) -> str:
Expand Down Expand Up @@ -4260,6 +4291,7 @@ def handle_get(handler, parsed) -> bool:
settings = load_settings()
if not settings.get("check_for_updates", True):
return j(handler, {"disabled": True})
include_agent_updates = not bool(settings.get("ignore_agent_updates"))
qs = parse_qs(parsed.query)
force = qs.get("force", ["0"])[0] == "1"
# ?simulate=1 returns fake behind counts for UI testing (localhost only)
Expand All @@ -4281,7 +4313,8 @@ def handle_get(handler, parsed) -> bool:
},
"agent": {
"name": "agent",
"behind": 1,
"behind": 1 if include_agent_updates else 0,
"ignored": not include_agent_updates,
"current_sha": "aaa0001",
"latest_sha": "bbb0002",
"branch": "master",
Expand All @@ -4293,7 +4326,7 @@ def handle_get(handler, parsed) -> bool:
)
from api.updates import check_for_updates

return j(handler, check_for_updates(force=force))
return j(handler, check_for_updates(force=force, include_agent=include_agent_updates))

if parsed.path == "/api/chat/stream/status":
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
Expand Down Expand Up @@ -4614,7 +4647,7 @@ def handle_post(handler, parsed) -> bool:
diag.stage("csrf")
if not _csrf_exempt_path(parsed.path) and not _check_csrf(handler):
try:
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
finally:
if diag:
diag.finish()
Expand Down Expand Up @@ -6194,7 +6227,7 @@ def _llm_update_summary(system_prompt: str, user_prompt: str) -> str:
def handle_patch(handler, parsed) -> bool:
"""Handle all PATCH routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_patch
Expand All @@ -6209,7 +6242,7 @@ def handle_patch(handler, parsed) -> bool:
def handle_delete(handler, parsed) -> bool:
"""Handle all DELETE routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_delete
Expand Down Expand Up @@ -6875,6 +6908,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
return True


def _html_preview_with_blank_base(raw: bytes) -> bytes:
base = '<base target="_blank">'
text = raw.decode("utf-8", errors="replace")
if re.search(r"<head(?:\s[^>]*)?>", text, flags=re.IGNORECASE):
text = re.sub(r"(<head\b[^>]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE)
elif re.search(r"<!doctype[^>]*>", text, flags=re.IGNORECASE):
text = re.sub(
r"(<!doctype[^>]*>)",
r"\1<head>" + base + "</head>",
text,
count=1,
flags=re.IGNORECASE,
)
else:
text = "<head>" + base + "</head>" + text
return text.encode("utf-8")


def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str):
"""Serve sandboxed workspace HTML preview with links targeting a new tab."""
try:
body = _html_preview_with_blank_base(target.read_bytes())
except PermissionError:
return bad(handler, "Permission denied", 403)
except Exception:
return bad(handler, "Could not read file", 500)

handler.send_response(200)
handler.send_header("Content-Type", "text/html; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Accept-Ranges", "none")
handler.send_header("Cache-Control", cache_control)
handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name))
handler.send_header("Content-Security-Policy", csp)
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("Referrer-Policy", "same-origin")
handler.send_header(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=(), clipboard-write=(self)",
)
handler.end_headers()
handler.wfile.write(body)
return True


def _handle_media(handler, parsed):
"""Serve a local file by absolute path for inline display in the chat.

Expand Down Expand Up @@ -7180,8 +7258,10 @@ def _handle_file_raw(handler, parsed):
# CSP sandbox directive applies the same isolation server-side: without
# allow-same-origin, the document is treated as a unique opaque origin and
# cannot read WebUI cookies, localStorage, or postMessage to the parent.
csp = "sandbox allow-scripts" if html_inline_ok else None
csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None
# _serve_file_bytes sends Content-Security-Policy when csp is set.
if html_inline_ok:
return _serve_inline_html_preview(handler, target, "no-store", csp=csp)
return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp)


Expand Down
19 changes: 15 additions & 4 deletions api/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
except ImportError:
_AGENT_DIR = None

_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
_SUMMARY_CACHE_MAX = 16
_summary_cache: OrderedDict = OrderedDict()
_cache_lock = threading.Lock()
Expand Down Expand Up @@ -521,11 +521,21 @@ def _check_repo(path, name):
return _check_repo_branch(path, name, fetch=False)


def check_for_updates(force=False):
def _ignored_agent_update_info() -> dict:
"""Return a stable update-check payload for intentionally ignored Agent updates."""
return {'name': 'agent', 'behind': 0, 'ignored': True}


def check_for_updates(force=False, *, include_agent=True):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
include_agent = bool(include_agent)
with _cache_lock:
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
if (
not force
and _update_cache.get('include_agent') == include_agent
and time.time() - _update_cache['checked_at'] < CACHE_TTL
):
return dict(_update_cache)
if _check_in_progress:
return dict(_update_cache) # another thread is already checking
Expand All @@ -534,12 +544,13 @@ def check_for_updates(force=False):
try:
# Run checks outside the lock (network I/O)
webui_info = _check_repo(REPO_ROOT, 'webui')
agent_info = _check_repo(_AGENT_DIR, 'agent')
agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info()

with _cache_lock:
_update_cache['webui'] = webui_info
_update_cache['agent'] = agent_info
_update_cache['checked_at'] = time.time()
_update_cache['include_agent'] = include_agent
return dict(_update_cache)
finally:
_check_in_progress = False
Expand Down
7 changes: 7 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ That's it for a real personal Docker install. Your existing `~/.hermes`
directory is mounted, your `~/workspace` is browsable, and the WebUI
auto-detects your UID/GID from the mounted volume.

The single-container setup runs the WebUI only. It can create cron jobs and run
them manually from the Tasks panel. In Docker, scheduled jobs require the Hermes gateway daemon
to tick while you are away. If System Settings shows `Gateway not configured`,
use `docker-compose.two-container.yml`,
`docker-compose.three-container.yml`, or run `hermes gateway` separately before
relying on offline scheduled runs.

For troubleshooting, reinstall, or onboarding reproduction trials, do not mount
your real `~/.hermes` unless you intentionally want to test real state. Use an
isolated Hermes home and follow
Expand Down
4 changes: 2 additions & 2 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ def log_request(self, code: str='-', size: str='-') -> None:
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
record = _json.dumps({
'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'method': self.command or '-',
'path': self.path or '-',
'method': getattr(self, 'command', None) or '-',
'path': getattr(self, 'path', None) or '-',
'status': int(code) if str(code).isdigit() else code,
'ms': duration_ms,
})
Expand Down
Loading
Loading