CI-only failure: test_session_db_close_is_idempotent imported hermes_state
from /home/hermes/.hermes/hermes-agent which exists locally but NOT on the
GH Actions runner that only has the WebUI repo.
Use importlib.util.find_spec to detect availability and pytest.skip when
the agent repo isn't present. The source-level pin in
test_cached_agent_reuse_closes_old_session_db catches revert of the close()
call; the runtime idempotency test is added confirmation when both repos
are co-located.
Local: 5 passed. CI: 4 passed + 1 skipped (idempotency).
Both docker-compose files had a UID mismatch between the agent
(defaults to 10000) and webui (defaults to 1000). When containers
share a volume, the webui gets Permission denied reading files
written by the agent.
- docker-compose.two-container.yml: add HERMES_UID/HERMES_GID
(was missing entirely)
- docker-compose.three-container.yml: change default from 10000
to 1000 to match webui's WANTED_UID/WANTED_GID
Fixes#1399
PR #1421 (SessionDB WAL handle leak fix on cached-agent reuse path) had a
sibling leak at the LRU eviction site that I caught during pre-review:
api/streaming.py SESSION_AGENT_CACHE.popitem(last=False) was discarding
the evicted entry with `evicted_sid, _ = ...`. The agent's _session_db
was dropped on the floor and only released when GC eventually finalized
the agent — which on a long-running server may be never (cyclic refs,
extension types holding C handles, etc.).
Same fix shape as #1421: capture the evicted entry, call
_evicted_agent._session_db.close() explicitly. SessionDB.close() is
idempotent + thread-safe (with self._lock: if self._conn:), so the
double-close-is-benign property still holds.
5 regression tests in test_v050259_sessiondb_fd_leak.py:
- Source-level: cached-agent reuse path closes before replace
- Source-level: LRU eviction path captures + closes evicted agent
- Behavioral: SessionDB.close() is idempotent (3 calls safe)
- Behavioral: cached-agent reuse with mock — close called exactly once
- Behavioral: LRU eviction with mock — only evicted agent's DB closes
Full suite: 3615 passed, 0 failed.
Nathan explicitly authorized 'just go ahead and merge it as a small release'
since the PR is 9 LOC, focused, has Opus pre-release follow-up + tests, and
matches the empirically-confirmed leak shape (73-handle leak at EMFILE).
PR #1419 (login session TTL + redirect-back + connectivity probe) had a
real bug in the server-side ?next= construction:
quote(path, safe='/:@!$&'()*+,;=') keeps ? and & literal, so:
(a) /api/sessions?limit=50&offset=0 round-trips as /api/sessions?limit=50
— the inner & terminates the outer next= value and offset=0 leaks as
a top-level outer query the login page ignores.
(b) An attacker-controlled path with embedded &next=https://evil.com
injects a second top-level next parameter. Browsers parse first-match
(benign), Python parse_qs parses last-match (the evil URL) — the
parser-divergence is a footgun even though _safeNextPath() in login.js
rejects the actual exploit.
Fix: encode the entire path?query blob with safe='/' so ?, &, = all
percent-encode. The outer next then holds exactly one path-with-query
string the browser auto-decodes once.
6 regression tests in test_v050258_opus_followups.py pin round-trip behavior
across simple paths, single-query, multi-param queries, attacker-injection
neutralization, and the SESSION_TTL=30d constant.
Full suite: 3610 passed, 0 failed.
SessionDB WAL handles leak when streaming.py creates a new SessionDB
instance per request and replaces the cached agent's _session_db without
closing the old one. Each orphaned connection holds 2 FDs (.db +
.db-wal), causing FD exhaustion and EMFILE crashes after ~73 messages.
Fix: close the previous _session_db before replacing it on cached
agents, mirroring the close-before-replace pattern used elsewhere in the
codebase.
When the server is unreachable (VPN/Tailscale off), the login page now
polls /health every 3 seconds instead of failing silently. Once the
server becomes reachable, the page reloads automatically so the user
doesn't have to manually refresh.
Opus pre-release advisor caught a 5th issue not covered by my initial
follow-up sweep, this one CRITICAL: PR #1402#493 per-session toolset
override silently no-op'd every time.
Bug: api/streaming.py:1755 called _session_meta.get('enabled_toolsets') on
the result of Session.load_metadata_only(). It returns a Session INSTANCE,
not a dict. .get() raised AttributeError, which the surrounding bare
except swallowed silently. The toolset chip in the UI saved correctly to
disk, but the streaming agent always ran with global toolsets.
Fix: use getattr(_session_meta, 'enabled_toolsets', None).
Two new regression tests:
- Source-level: forbid the .get() / [] dict-access shape.
- Runtime: Session.load_metadata_only must return a Session instance.
Full suite: 3604 passed, 0 failed.
stage-257 batch (PRs #1402 + #1415):
Opus pre-release advisor caught 4 issues in stage-257:
1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace()
preserves the temp file umask (0644 default), so OAuth access/refresh
tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600)
BEFORE rename, with try/except OSError that warns but does not abort.
2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted
job_id as a path component without validation. Mirrors the rollback
path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT
normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit
. / .. rejection.
3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised
ValueError on malformed input → confusing 500. Now try/except + clamp
to (max(0, offset), max(1, min(500, limit))).
4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth
even though path-resolve check would catch it downstream).
PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
test_custom_provider_display_name asserted bare model IDs but #1415
changes named-custom-provider IDs to @custom:NAME:model form when active
provider differs. Tests updated to use _strip_at_prefix helper to keep
checking the same invariant in the new shape.
4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing
tests. Full suite: 3602 passed, 0 failed.
The li() helper in static/icons.js logs console.warn and returns ''
when an icon name is not in LI_PATHS. Five icon names referenced by
static/*.js were never registered, so their host elements rendered as
empty 0-size buttons / containers despite display:flex.
Five missing icons added:
- 'volume-2' — TTS speaker on every assistant message
(ui.js:3376; regression from #499; surfaced after
#1411 fixed CSS specificity in v0.50.255)
- 'chevron-up' — queue pill chevron (ui.js:2178; the '▲' fallback
only fired when li was undefined, not when it
returned '')
- 'hash' — Insights 'Messages' stat card (panels.js:883)
- 'cpu' — Insights 'Tokens' stat card (panels.js:884)
- 'dollar-sign' — Insights 'Cost' stat card (panels.js:885)
The Insights icons are a fresh regression from #1405 (v0.50.255).
Adds tests/test_issue1413_li_path_coverage.py — three tests:
1. Walk every li('NAME', ...) call across static/*.js, assert NAME
is registered in LI_PATHS. Prevents the entire class of bug.
2. Pin the five icons added by this fix so removal gets a clear
error message.
3. Pin the warn+empty-string contract of li() so the diagnostic
story in the test docstring stays accurate.
Reported by @AvidFuturist via Telegram, 2026-05-01.
Fixes#1413
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)
OAuth keys (oauth_codex_*, oauth_login_codex), session toolset keys
(session_toolsets_*), and usage_personality_none were missing from zh,
zh-Hant, ko, ru, es, de, pt locale blocks.
All keys added as English placeholders with '// TODO: translate' comments
to unblock locale coverage CI gates.
Fixes: CI failure on 4 locale coverage tests
- 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.