* fix(auth): prune expired sessions on every verify to prevent memory leak
The in-memory _sessions dict accumulated expired tokens indefinitely —
entries were only removed when that specific token was verified. Add a
lazy _prune_expired_sessions() call at the top of verify_session() so
all expired entries are swept during normal traffic.
Addresses #192.
* test(auth): add 8 unit tests for session lifecycle and lazy pruning
Tests verify:
- Fresh session creation and validation
- Expired entries are pruned during verify_session() calls
- Valid sessions are never removed by pruning
- Empty dict is safe for pruning
- Session TTL matches expected 24-hour window
- invalidate_session() actually removes the token
- Invalidating non-existent tokens is safe
Set Handler.timeout = 30. Python's BaseHTTPRequestHandler.setup()
calls self.request.settimeout(timeout), which raises socket.timeout
on idle or slow connections after the configured duration.
This defends against Slowloris-style attacks where a client holds
connections open indefinitely, exhausting threads in ThreadingHTTPServer.
Also recovers threads from crashed clients with hung TCP connections.
Addresses #194.
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Agent review findings from PR #179:
1. static/ui.js line 542: extra } in ternary produced malformed HTML
in message bubble div (''}} instead of ''}). Caused a literal }
character to appear in the DOM.
2. api/routes.py: LOGIN_INVALID_PW and LOGIN_CONN_FAILED were inserted
into JS string context without JS-string escaping. Added backslash
escaping for ' and \ characters. Currently safe because locale values
are hardcoded, but this prevents breakage if custom locale strings
contain single quotes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add applyLocaleToDOM() which walks [data-i18n] elements and re-stamps
their textContent from t(). Called after setLocale() in saveSettings()
so the settings panel labels, checkboxes, and save button update live.
Also called on boot after /api/settings resolves so Chinese persists
without flicker on reload.
- static/i18n.js: add applyLocaleToDOM() function
- static/index.html: add data-i18n attributes to all settings panel
static text nodes (labels, checkbox spans, save button)
- static/panels.js: call applyLocaleToDOM() + syncTopbar() after save
- static/boot.js: call applyLocaleToDOM() alongside setLocale() on boot
Introduces a locale bundle system that makes UI language switchable at
runtime and trivially extensible to any future language.
Architecture:
- static/i18n.js: LOCALES object with 'en' and 'zh' bundles, t(key)
helper with English fallback, setLocale()/loadLocale() for persistence
via localStorage. Adding a new language = adding one object.
- api/config.py: 'language' setting (default 'en'), BCP-47 validation
- api/routes.py: _LOGIN_LOCALE dict for server-rendered login page;
template placeholders substituted at request time from saved setting
- static/index.html: loads i18n.js first (before other scripts); adds
Language dropdown to Settings panel, auto-populated from LOCALES
Wiring:
- boot.js: applies server-persisted locale at startup (after /api/settings
fetch); speech recognition lang follows _locale._speech
- panels.js: populates Language dropdown from LOCALES on settings open;
saves + applies locale on Save Settings
- All JS files: hardcoded user-facing strings replaced with t() calls
Coverage:
- test_sprint20.py: relaxed recognition.lang assertion to accept dynamic
locale-driven assignment (behavior unchanged for English default)
- 499/499 tests pass
Closes#177 (incorporates Chinese translations as a proper locale bundle
rather than hardcoded strings, so English default is fully preserved)
Agent review findings:
- _soundEnabled/_notificationsEnabled not updated in the password-save
early-return branch of saveSettings() — fixed
- AudioContext never closed after oscillator finishes — added osc.onended
callback to ctx.close() preventing resource accumulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add two new settings (both default off):
- sound_enabled: plays a short tone via Web Audio API when assistant
finishes a response or requests approval
- notifications_enabled: shows a browser notification when a response
completes while the tab is in the background
Uses Web Audio API (oscillator) instead of bundled MP3 file — zero
additional assets. Follows the standard 4-file settings pattern.
Also skip test_valid_skill_accepted when hermes-agent not installed
(skills endpoint returns 500 without the agent module).
Inspired by #176 (DavidSchuchert)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The v0.39.0 security sprint introduced _ENV_LOCK to protect env var
mutations in the streaming path. The implementation held the lock for
the entire agent run (potentially minutes), then tried to re-acquire
it in the finally block — a guaranteed deadlock on any non-reentrant
threading.Lock().
Result: first message completes (done event fires before finally hits),
but the lock is never released. Every subsequent chat/start POST blocks
forever waiting for that lock.
Fix: narrow the lock scope to just the env mutation. Set the vars inside
the with block, then let the lock release before the agent starts. The
finally block re-acquires cleanly since it no longer re-enters an
already-held lock.
No logic change — only the critical section boundary moves.
Hermes stores reasoning as a top-level message field (m.reasoning)
instead of in content arrays like Claude. This patch makes the
thinking/reasoning card also check for m.reasoning, so users can
see the model's reasoning process in the WebUI.
- Added --input-bg and --hover-bg CSS variables to OLED theme
- Added OLED row to built-in themes table in THEMES.md
- Updated theme count from six to seven
True black background with subtle borders for OLED displays.
Pure #000 backgrounds, low-opacity borders, and warm accent colors
to minimize burn-in risk and maximize contrast.
* fix: sync message_count to state.db for /insights (#163)
sync_session_usage() didn't write message_count to state.db, so
/insights showed 0 messages for all WebUI sessions even with
sync_to_insights enabled.
Added message_count parameter to sync_session_usage() and pass
len(s.messages) from both the streaming and non-streaming chat paths.
Fixes#163
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use callable pattern for _execute_write in sync_session_usage
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tool cards disappeared on page refresh because assistant messages with
only tool_use content (no text) were filtered out of the visible
messages list. Since tool cards anchor to DOM rows via data-msg-idx,
removing the anchor row meant cards had nothing to attach to.
Fix: keep assistant messages in the render list if they contain
tool_use blocks, even when they have no text content. The row renders
with the role label but empty body, providing an anchor point for the
tool card insertion pass.
Fixes#140
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous implementation read SOUL.md files from a filesystem directory.
The Hermes agent uses config.yaml agent.personalities section with string
or dict format (system_prompt, tone, style), resolved via
_resolve_personality_prompt() and passed to AIAgent via
ephemeral_system_prompt.
Changes:
- /api/personalities: reads from config.yaml agent.personalities, not
filesystem SOUL.md directories. Calls reload_config() to pick up
config changes without restart.
- /api/personality/set: resolves prompt from config.yaml using the same
logic as hermes-agent cli.py (string or dict with system_prompt/tone/style)
- streaming.py: passes personality via agent.ephemeral_system_prompt
(agent's own mechanism) instead of prepending to system_message
- Removed unused 're' import from streaming.py
- Updated tests to match config-based approach
Fixes#139
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous fix (#142) prefixed non-default provider models with
'provider/model' which then hit the cross-provider guard and routed
to OpenRouter — worse than before for users without an OpenRouter key.
New approach: non-default provider models use '@provider:model' format
(e.g. @minimax:MiniMax-M2.7). resolve_model_provider() parses this
hint and returns (bare_model, provider, None). streaming.py and
routes.py then pass the resolved provider to
resolve_runtime_provider(requested=provider) which gets the correct
per-provider API key and base_url from hermes-agent.
This uses the agent's own credential resolution instead of reinventing
routing logic in the webui.
Fixes#138
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The model ID `claude-haiku-3-5` does not exist on Anthropic's API and
returns HTTP 404. The correct model is `claude-haiku-4-5` (Claude Haiku 4.5).
Fixes both `_PROVIDER_MODELS` and `_FALLBACK_MODELS` lists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>