Opus pre-release advisor caught 4 issues in stage-255 (#1390 + #1405):
1. MUST-FIX: api/rollback.py path-traversal — _checkpoint_root() / ws_hash /
checkpoint did NOT normalize Path() / "../escape", so an authenticated
caller could read or restore from another allowlisted workspace via
../<other-ws-hash>/<sha>. New _validate_checkpoint_id() regex-guards
with ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ and rejects . and .. literals.
Both get_checkpoint_diff and restore_checkpoint validate.
2. SHOULD-FIX: redact_session_data perf cliff — the new api_redact_enabled
toggle in #1405 called uncached load_settings() per string, recursed
across messages[] and tool_calls[]. For a 50-message session: hundreds
of disk reads per /api/session response. Now read once at the top and
thread _enabled through via private kwarg.
3. SHOULD-FIX: voice-mode wrong-session TTS — the patched autoReadLastAssistant
fires globally; if the user navigated to a different session between
sending and stream completion, TTS would speak the wrong session\\s reply.
New _voiceModeThinkingSid closure captures S.session.session_id at
thinking-time; _speakResponse bails to _startListening() on mismatch.
4. NIT: rollback._inspect_checkpoint had bare Exception in the except tuple
alongside specific catches, swallowing everything. Now (TimeoutExpired,
OSError) only.
6 regression tests in test_v050255_opus_followups.py. Full suite: 3587 passed,
2 skipped, 3 xpassed.
Two unrelated UX/Settings bugs, both small surgical fixes with regression
tests.
Issue #1409 — TTS toggle has no effect
=======================================
Reported via Discord: ticking Settings → Voice → "Text-to-Speech for
responses" did nothing. The speaker icon never appeared on assistant
messages despite the checkbox saving to localStorage correctly.
Root cause (CSS specificity collision):
static/panels.js _applyTtsEnabled() set
btn.style.display = enabled ? '' : 'none'
on every .msg-tts-btn. The '' branch removes the inline override, after
which the .msg-tts-btn { display:none; } rule from style.css re-hides the
button. Both branches left the icon hidden, so the toggle has been
silently broken since #499 first shipped the TTS feature.
Fix (body-class toggle, Option B from the issue):
- panels.js: _applyTtsEnabled now toggles body.classList('tts-enabled')
- style.css: new compound selector
body.tts-enabled .msg-tts-btn { display:inline-flex; align-items:center; }
- default-hidden rule (.msg-tts-btn{display:none;}) preserved so the icon
stays hidden by default (CSS-only state)
- boot.js paths that already call _applyTtsEnabled(localStorage…) work
unchanged — the new function applies state at the body level instead of
inline-styling individual buttons, so the rule survives renderMd()
re-renders without re-querying every button
Verified end-to-end against live server: getComputedStyle on a probe
.msg-tts-btn returns display:flex when body has tts-enabled, display:none
when it doesn't. Two regression tests in TestIssue1409TtsToggleBodyClass
explicitly check for the body-class shape and forbid the broken inline-style
pattern.
Issue #1410 — Ollama (local) shows "API key configured" when only
Ollama Cloud key is set
=================================================================
Reported via Discord: configuring Ollama Cloud lit up the local Ollama card
too. Both providers were mapped to OLLAMA_API_KEY in api/providers.py
_PROVIDER_ENV_VAR.
Root cause:
api/providers.py:47-48
"ollama": "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
_provider_has_key("ollama") found the value the user set for Ollama Cloud
and returned True. But the runtime code path in
hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the base
URL hostname is ollama.com (Ollama Cloud) — local Ollama is keyless by
default and reaches a custom base URL with no auth. The WebUI was
reporting "configured" for a key local Ollama doesn't even read.
Fix (Option A from the issue body, preferred):
- Drop bare "ollama" from _PROVIDER_ENV_VAR with an inline comment
explaining why
- _provider_has_key("ollama") falls through to the config.yaml branch,
which already supports providers.ollama.api_key for local users who
genuinely need to set a token
- ollama-cloud retains its OLLAMA_API_KEY mapping unchanged
Verified end-to-end against live server with OLLAMA_API_KEY=sk-cloud-key-test
in env: GET /api/providers reports has_key=True only for ollama-cloud, and
has_key=False for bare ollama. Two regression tests in
TestIssue1410OllamaEnvVarBleed cover the bleed-prevention case AND the
"local user with config.yaml api_key still reports configured" case to
guard against over-correction.
Tests
-----
3572 passed, 2 skipped, 3 xpassed (was 3567 — added 5 new regression tests).
Closes#1409Closes#1410
Reported by @AvidFuturist (Discord, May 1 2026)
- popstate handler now refuses to switch sessions mid-stream (S.busy guard)
Mirrors the same guard the cross-tab storage handler had. PR #1392 added
the popstate listener but missed this. Without it, browser Back during
a live stream silently yanks the user out of their turn.
(Opus pre-release advisor finding)
- CHANGELOG entry for v0.50.254 (4 PRs + 1 Opus follow-up)
1 regression test in test_v050254_opus_followups.py.
- Point 4 (security): _resolve_workspace now validates against known workspaces
from workspaces.json to prevent arbitrary path write via restore endpoint
- Point 5 (voice mode): bail out of voice mode on not-allowed, service-not-allowed,
and audio-capture errors instead of infinite retry loop
- Point 1 (locale coverage): added ~40 new English keys as placeholders with
TODO:translate comments in zh, zh-Hant, ko, ru, es, de, pt locales
- Point 2 (test fix): tightened test regex to anchor on branch-indicator class
to avoid collision with _sessionLineageKey helper
- Point 3 (test fix): accept both inline and parentEl variable forms for
body.appendChild pattern in pinned indicator test
All 6 previously failing tests now pass.
The Settings toggle label previously said 'Show CLI sessions' or 'Show
agent sessions', but the feature actually surfaces conversations from
CLI, Telegram, Discord, Slack, WeChat, and other non-WebUI channels.
- Rename i18n key: settings_label_cli_sessions → settings_label_external_sessions
- Rename i18n key: settings_desc_cli_sessions → settings_desc_external_sessions
- Update all 8 languages (en, zh, zh-TW, ru, es, de, pt, ko)
- Reorder channel examples by global adoption: Telegram, Discord, Slack
- Update HTML fallback text to match new English strings
Fixes#1394 — _combined_redact() crashes with TypeError on older
hermes-agent builds that lack the 'force' kwarg in redact_sensitive_text().
Wrap the call in try/except to gracefully fall back.
Fixes#1397 — Two bugs in the code block tree-view renderer:
1. Newlines in data-raw HTML attribute are collapsed to spaces by the
browser (HTML spec). Encode \n as to preserve multi-line content.
2. jsyaml lazy-load was never triggered when the library wasn't loaded yet.
Now defers init and retries after _loadJsyamlThen() completes.
Fixes#1389 — fix_credential_permissions() now honors HERMES_SKIP_CHMOD=1
as a complete bypass, and when HERMES_HOME_MODE is set, only strips world
bits (0o007) instead of forcing chmod 0600 — preserving intentional group
access for Docker setups.
Clicking a chat in the sidebar now processes immediately when using a mouse or
trackpad, but introduces a 300ms delay on touch devices to prevent accidental
navigation when a user scrolls the sidebar and lifts their finger mid-gesture.
Drag is detected when the pointer moves more than 5px from the pointerdown
position; a detected drag cancels any pending tap on release and suppresses
the hover highlight via a .dragging class added synchronously and removed
after a 50ms defer to prevent :hover activating before class removal settles.
The double-tap-to-rename path is unaffected.
Detection uses e.pointerType (already available on the pointerup event) rather
than user-agent sniffing.
Three small fixes from Opus review of the merged stage diff:
1. Strip 9 orphan wiki_* i18n keys (72 lines) from PR #1342 — leaked
from a different branch, zero references outside i18n.js.
2. /branch endpoint: reject non-string session_id with explicit 400
(was raising TypeError → generic 500 from get_session()).
3. /branch endpoint: reject negative keep_count with explicit 400
(Python slice semantics on negative produces 'all but last N',
confusing fork behavior).
Plus tests/test_v050253_opus_followups.py — 3 regression tests pinning
all three fixes.
Verified: 3558 pytest passing.
Pulls in the extra commit pushed to PR #1381 after our initial absorb. Adds a
@media (max-width: 340px) block that compacts gutters (composer-wrap padding,
composer-footer gap, composer-left gap) without shrinking the 44px touch
targets. Plus its regression test.
Verified with apply --check failed but actual apply succeeded — the failure
was due to context drift from our earlier CSS specificity fix; the new lines
landed at the correct location. test_mobile_layout.py: 47 tests passing.
The .composer-mobile-config-btn{display:none} base rule was at line 896 but
.icon-btn{display:flex} (the button's other class) was at line 941 — equal
specificity, but later in source wins. Result: the button was visible at
desktop widths, sandwiched between the workspace and model chips.
Bumping the base rule's selector to .icon-btn.composer-mobile-config-btn
gives it specificity 0,0,2,0 (vs .icon-btn at 0,0,1,0), so it always wins
the cascade. The two narrow-viewport rules already use !important and remain
unaffected — desktop hides cleanly, mobile shows correctly.
Verified via Agent Browser CDP: 1440x900 desktop now shows the standard
chips only (no extra config button); iPhone 14 mobile shows the new compact
config btn at 44x44 with the panel toggling correctly. Screenshots:
/tmp/may2-shots/desktop-final.png, mobile-{closed,open}-final.png
PR #1342's rewrite introduced `del sys.modules['api.config']`, 'api.profiles']`
anti-pattern that breaks tests/test_live_models_ttl_cache.py::test_live_models_cache_is_profile_scoped
(v0.50.252) when run after test_issue1195_*. The pattern is explicitly banned per
~/WebUI/docs/agent-memory/pytest-isolation.md — sibling tests that import api.profiles
later see the wrong (re-imported) module.
Master's version of this test passes 5/5 and uses no del sys.modules calls. The PR's
core /branch feature does NOT depend on this test rewrite — reverting it loses no
coverage of the branching feature.
Fix: gate parent_session_id emission in compact() on truthiness so
sessions without a fork link don't leak parent_session_id: None and
break the v0.50.251 lineage end_reason gating in agent_sessions.py.
The /branch endpoint sets the field on saved forks; everything else
keeps the v0.50.251 sidebar lineage path as the canonical source.
Persist session model_provider separately from model IDs so active/default provider selections like gpt-5.5 remain bare while routing through OpenAI Codex. Keep @provider:model for picker disambiguation and runtime bridging, and preserve explicit OpenRouter plus custom/proxy base_url routing.
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
Adds two more contributor PRs to the v0.50.251 batch per user
directive (per-PR review + Opus review for #1373; #1375 was clean
ship-on-sight).
#1375 (@bergeouss, +382 LOC, all CI green) — fixes#1361 paid-token
data loss on Stop/Cancel. Mirrors the existing STREAM_PARTIAL_TEXT
pattern from #893: adds STREAM_REASONING_TEXT and STREAM_LIVE_TOOL_CALLS
shared dicts populated during streaming and read by cancel_stream().
Also fixes the §C reasoning-only-creates-no-message gap where the
strip-thinking-blocks regex returned empty string and the if-guard
skipped the partial append. 8 regression tests covering all 3
sections plus tools+text combinations.
#1373 (@bergeouss, +105 LOC, had CI failures pre-fix) — fixes#1195
new-profile-routes-to-default. The is_dir() guard in
get_hermes_home_for_profile() caused new profiles (no session yet)
to silently route every session back to the default profile until
the directory existed on disk. Removed the guard; profile path is
now returned unconditionally.
Pre-release fix for #1373's CI failures: the change flipped two
behaviors pinned by tests in #798:
- R19c (test_get_hermes_home_for_profile_falls_back_for_missing_profile)
asserted nonexistent → base. Renamed and updated to assert the
new always-return-profile-path behavior.
- R19j (test_get_hermes_home_for_profile_rejects_path_traversal)
asserted that valid-but-nonexistent profile names → base. Updated
to assert profile-scoped path. Also updated docstring: the
_PROFILE_ID_RE regex is now the SOLE defense against path
traversal (previously is_dir() was a defense-in-depth layer);
verified each known-bad shape still returns base.
Tests: 3484 passing (3471 → 3484, +13).
When a user switched profiles and created a new session, the session
was saved to the default profile directory instead of the active
profile directory — because get_hermes_home_for_profile() silently
fell back to _DEFAULT_HERMES_HOME when the profile directory didn't
exist yet on disk.
Root cause: api/profiles.py:156 had `if profile_dir.is_dir(): return
profile_dir; return _DEFAULT_HERMES_HOME`. New profiles (no session
yet, so no dir) routed every session back to default.
Fix: remove the is_dir() guard, return the profile path
unconditionally. The profile directory is created on first use by
the agent/session layer.
5 regression tests in tests/test_issue1195_session_profile_routing.py:
existing-profile, non-existent-profile (the core fix), None, empty-
string, 'default' all return the expected path.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Three distinct data-loss paths fixed:
§A — Reasoning text was accumulated in a thread-local _reasoning_text
inside _run_agent_streaming. cancel_stream() never saw it because it
went out of scope when the thread was interrupted. Now mirrored to a
new shared dict STREAM_REASONING_TEXT keyed by stream_id, populated
in on_reasoning() and the reasoning branch of on_tool(), read in
cancel_stream().
§B — Live tool calls in thread-local _live_tool_calls were similarly
invisible to cancel_stream(). Now mirrored to STREAM_LIVE_TOOL_CALLS
on tool.started + tool.completed.
§C — Reasoning-only streams produced no partial message because the
thinking-block regex strip returned empty string and the `if _stripped:`
guard skipped the append. Now appends the partial message when EITHER
content text, reasoning trace, OR tool calls exist.
Mirrors the existing STREAM_PARTIAL_TEXT pattern from #893 exactly:
same dict creation in _run_agent_streaming, same _live_config fallback
in cancel_stream, same cleanup in _periodic_checkpoint.
8 regression tests in tests/test_issue1361_cancel_data_loss.py
covering all three sections plus tools+text combinations.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Opus pre-release findings on #1370 applied:
SHOULD-FIX 1: Tightened parent_session_id exposure to only emit when
the parent's end_reason is in {compression, cli_close}. Without this,
two distinct WebUI sessions sharing a non-continuation parent (e.g.
'user_stop') would get clustered by frontend's _sessionLineageKey
(which falls through to parent_session_id when _lineage_root_id is
missing) and incorrectly collapsed into a single sidebar row.
Updated assertions in:
- tests/test_session_lineage_metadata_api.py::
test_non_compression_state_db_parent_does_not_create_sidebar_lineage
- tests/test_pr1370_lineage_metadata_perf_and_orphan.py::
test_non_compression_parent_does_not_extend_lineage
SHOULD-FIX 2: Chunked the IN-clause to 500 vars to stay under
SQLITE_MAX_VARIABLE_NUMBER. Python 3.9 ships sqlite 3.31 with the
default limit of 999. A power user with 2000+ sessions in the
sidebar would hit OperationalError, the silent except-wrapper would
swallow it, and lineage collapse would never work. Added
test_in_clause_chunked_for_large_session_set with SQL interception
to lock the invariant in source.
PR addition (per user directive — Opus + my review, no second
independent review round needed for combined batch):
#1372 from @NocGeek — fix: persist manual cron run results.
Self-contained 89 LOC fix split out from the held #1352. Mirrors the
scheduled-cron path (cron/scheduler.py:1334-1364) exactly: saves
output, marks job complete, treats empty response as soft failure
with matching error string. 2 behavioral tests using sys.modules
monkeypatch to mock cron.scheduler.run_job. CI not yet attached
because branch is brand-new; ran the new tests + adjacent suites
locally — all pass.
Final test count: 3471 passing, 0 failed.
Also adds 2 more regression tests for the perf-fix invariants:
- test_in_clause_chunked_for_large_session_set
- test_two_children_sharing_non_continuation_parent_not_collapsed