Compare commits

...

67 Commits

Author SHA1 Message Date
nesquena-hermes
4d68fb31d4 docs: v0.40.2 release — approval UI, 547 tests (#188)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 20:17:14 -07:00
nesquena-hermes
80b26c7c72 fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* 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>
2026-04-08 20:16:22 -07:00
nesquena-hermes
012ac6f149 docs: v0.40.1 release — default locale fix (#186)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 19:35:41 -07:00
nesquena-hermes
18aca24063 fix: default first-install locale to English (#185)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 19:35:03 -07:00
nesquena-hermes
a5b843d6f9 docs: v0.40.0 release — i18n, notifications, thinking display (#184)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-08 19:19:02 -07:00
Nathan Esquenazi
9714c1779f Merge pull request #183 from nesquena/fix/i18n-review-fixes
fix: stray } in message HTML + JS-escape login locale strings
2026-04-08 19:07:29 -07:00
Nathan Esquenazi
0126044ecb fix: stray } in message row HTML + JS-escape login locale strings
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>
2026-04-08 19:07:00 -07:00
Nathan Esquenazi
166a4c3e7b Merge pull request #179 from nesquena/feat/i18n-language-switcher
feat: pluggable i18n with English/Chinese language switcher in Settings
2026-04-08 18:59:11 -07:00
Nathan Esquenazi
1ac1e74512 fix: apply locale to DOM immediately on save — no reload needed
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
2026-04-08 18:58:20 -07:00
Nathan Esquenazi
b979b4c443 feat: pluggable i18n with English/Chinese language switcher in Settings
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)
2026-04-08 18:57:50 -07:00
Nathan Esquenazi
c04caf3f5b Merge pull request #180 from nesquena/feat/notification-sound-browser
feat: notification sound and browser notifications
2026-04-08 18:56:11 -07:00
Nathan Esquenazi
799cbb7eca fix: update sound/notification globals in password branch + close AudioContext
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>
2026-04-08 18:55:57 -07:00
Nathan Esquenazi
0d83837650 Merge pull request #182 from nesquena/fix/thinking-display-edge-cases
fix: harden thinking display streaming edge cases
2026-04-08 18:51:06 -07:00
Nathan Esquenazi
5f7564e8bb fix: harden thinking block streaming display
Hide partial <think> tag prefixes during streaming and rename the local display variable for clarity. References #181.
2026-04-08 18:14:47 +00:00
TaraTheStar
8ff5d83e14 feat: add support for displaying thinking/reasoning blocks in chat 2026-04-08 18:14:09 +00:00
Nathan Esquenazi
5e899ee8fe feat: notification sound and browser notifications on task completion
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>
2026-04-08 09:02:02 -07:00
Nathan Esquenazi
907bb224d9 Merge pull request #178 from nesquena/fix/streaming-env-lock-deadlock
Some checks failed
Release & Docker / release (push) Has been cancelled
fix: resolve _ENV_LOCK deadlock that blocks chat after first message
2026-04-08 07:26:53 -07:00
Nathan Esquenazi
d919b584c6 docs: v0.39.1 release notes for ENV_LOCK deadlock fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 07:26:41 -07:00
Nathan Esquenazi
4422a87de9 fix: resolve _ENV_LOCK deadlock that blocks chat after first message
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.
2026-04-08 14:22:39 +00:00
nesquena-hermes
9e9fcb09d2 Fix broken link in Quick start section (#175) 2026-04-07 23:39:33 -07:00
nesquena-hermes
12e5de9c4e Refine README for clarity and correctness (#174)
Updated the README to clarify installation steps and improve grammar.
2026-04-07 23:38:44 -07:00
nesquena-hermes
7e6fec1c85 docs: sweep TESTING.md, SPRINTS.md, ROADMAP.md to v0.39.0 / 499 tests
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-07 22:33:08 -07:00
nesquena-hermes
a064542df9 release: v0.39.0 — security hardening, 12 fixes (#171)
Some checks failed
Release & Docker / release (push) Has been cancelled
* Security: harden auth, CSRF, SSRF, XSS, and env race conditions

Twelve fixes from a full security audit:

CRITICAL
- Add CSRF Origin/Referer validation on all POST endpoints
  (prevents cross-origin abuse of self-update, settings, file ops)

HIGH
- Unify password hashing: config.py now uses PBKDF2 (600k iters)
  instead of single-iteration SHA-256
- Add per-IP rate limiting on login (5 attempts/60s, 429 on excess)

MEDIUM
- Validate session IDs as hex-only before filesystem operations
  (prevents path traversal via crafted session ID)
- SSRF: resolve DNS before private-IP check in model fetching
  (prevents DNS rebinding to internal services)
- Warn loudly when binding non-loopback without password set
- SSE env var mutations: wrap sync chat + streaming restore in _ENV_LOCK
- Force Content-Disposition:attachment for HTML/XHTML/SVG uploads
  (prevents stored XSS via uploaded files)

LOW
- Extend HMAC session signature from 64 to 128 bits
- Add resolve()+relative_to() check on skills path construction
- Set Secure flag on session cookie when connection is HTTPS
- Sanitize exception messages to strip filesystem paths

No breaking changes. All fixes are backward-compatible.

* fix: use getattr for Secure cookie SSL detection

handler.request.getpeercert raises AttributeError on plain sockets
(non-SSL). Use getattr(..., None) to safely check for SSL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* tests: add sprint 29 security hardening coverage (PR #171)

33 tests covering all 12 security fixes:
- CSRF origin/referer validation
- Login rate limiting (5 attempts/60s)
- Session ID hex validation (path traversal prevention)
- Error path sanitization (_sanitize_error)
- Secure cookie getattr safety
- HMAC signature length (64->128 bit)
- Skills path traversal prevention
- Content-Disposition for HTML/SVG/XHTML
- PBKDF2 password hashing verification
- Non-loopback startup warning
- SSRF DNS guard code presence
- _ENV_LOCK export from streaming module

* release: v0.39.0 — security hardening, 12 fixes (#171)

---------

Co-authored-by: betamod <matthew.sloly@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:26:03 -07:00
Nathan Esquenazi
ac969e4bd6 Merge pull request #169 from tgaalman/fix/hermes-reasoning-display
Fix: show top-level reasoning field in thinking card
2026-04-07 12:01:23 -07:00
root
67a83368f0 Fix: show top-level reasoning field in thinking card
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.
2026-04-07 20:44:55 +02:00
Nathan Esquenazi
a9b2a2f22f Merge pull request #168 from kevin-ho/oled-theme
feat: add OLED theme for true black displays
2026-04-07 11:12:05 -07:00
Kevin Ho
e3303c6e89 fix: add missing --input-bg/--hover-bg vars, update THEMES.md
- 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
2026-04-07 18:11:01 +00:00
Kevin Ho
40cbd024b9 feat: add OLED theme
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.
2026-04-07 17:56:57 +00:00
nesquena-hermes
ccabdf9882 docs: update testing plan version coverage to v0.38.6 (#167)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 23:15:56 -07:00
nesquena-hermes
70a486ddef docs: sweep sprint and testing counts to v0.38.6 (#166)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 23:15:21 -07:00
nesquena-hermes
ab6147fba9 release: v0.38.6 — insights message count fix (#165)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 22:56:54 -07:00
Nathan Esquenazi
8aa1c9684d fix: sync message_count to state.db for /insights (#163) (#164)
* 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>
2026-04-06 22:56:27 -07:00
nesquena-hermes
4d2887531d release: v0.38.5 — custom endpoint URL, custom_providers, .env key fix (#161)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:39:37 -07:00
nesquena-hermes
d6de7c8650 fix: custom endpoint URL, custom_providers in dropdown, .env key resolution (#157) (#160)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:39:19 -07:00
nesquena-hermes
76241bc255 release: v0.38.4 — exclude ambient gh token from provider detection (#159)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:35:52 -07:00
nesquena-hermes
5b4c5b0094 fix: exclude ambient gh-cli token from model dropdown provider detection (#158)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:35:30 -07:00
nesquena-hermes
027e7314f0 release: v0.38.3 — model dropdown uses hermes auth (#156)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:29:33 -07:00
nesquena-hermes
107c446187 fix: model dropdown shows only hermes-configured providers (#155)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:29:06 -07:00
nesquena-hermes
01896d67f3 release: v0.38.2 — tool cards properly render on page reload (#154)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:23:54 -07:00
nesquena-hermes
5a52259fd7 fix: tool cards actually render on page reload from session data (#140) (#153)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:23:26 -07:00
nesquena-hermes
d71daad002 release: v0.38.1 — model selector duplicate + stale label fixes (#152)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:16:26 -07:00
nesquena-hermes
481eefaf91 fix: model selector duplicate + stale model label (#147) (#151)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:15:24 -07:00
nesquena-hermes
534eefe09a release: v0.38.0 — model routing, personality config.yaml, tool card reload (#150)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 14:11:41 -07:00
Nathan Esquenazi
d89639dbb3 fix: tool call cards persist across page reload (#140) (#149)
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>
2026-04-06 14:10:33 -07:00
Nathan Esquenazi
2442fca5e5 fix: personalities from config.yaml + ephemeral_system_prompt (#139) (#148)
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>
2026-04-06 14:10:30 -07:00
Nathan Esquenazi
442b0d872a fix: multi-provider model routing via @provider: hint (#138) (#146)
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>
2026-04-06 14:10:26 -07:00
Nathan Esquenazi
3bba645364 Merge pull request #145 from jeffscottward/fix/claude-haiku-model-id
fix: correct Claude Haiku model ID from 3-5 to 4-5
2026-04-06 13:27:31 -07:00
Jeff Scott Ward
5f014b7c4a fix: correct Claude Haiku model ID from 3-5 to 4-5
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>
2026-04-06 15:49:22 -04:00
nesquena-hermes
cd598c896a docs: v0.37.0 release notes, version bump, test count (465 tests) (#144)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 11:19:19 -07:00
Nathan Esquenazi
58eb6e7fd5 feat: /personality slash command with backend integration (#143)
* feat: /personality slash command with backend integration

Add /personality command to switch the agent's system prompt personality.
Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md.

Backend:
- GET /api/personalities: lists available personalities from the active
  profile's personalities directory (reads first line of SOUL.md for desc)
- POST /api/personality/set: sets active personality on the session, reads
  and validates the SOUL.md file exists, returns the prompt text
- streaming.py: injects personality prompt (SOUL.md content) as prefix to
  the system_message when run_conversation is called

Frontend (commands.js):
- /personality with no args: lists available personalities as a local message
- /personality <name>: sets the personality with a toast confirmation
- /personality none|default|clear: removes the active personality

Session model: new 'personality' field (backward-compatible, defaults to None)

Closes #139
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: path traversal in personality name + case sensitivity

Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
in both routes.py (POST /api/personality/set) and streaming.py (system
prompt injection). Defense-in-depth: resolve().relative_to() check ensures
the path stays inside the personalities directory even if regex is bypassed.

Also: removed toLowerCase() from frontend command handler so personality
names are case-preserved (filesystem may be case-sensitive).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: /personality command — hardened, compact() fix, tests

Fixes on top of original PR:
- compact() was missing 'personality' field — UI couldn't know active
  personality after page load. Added to Session.compact().
- GET /api/personalities: add symlink guard (is_symlink() skip) and
  resolve() check — prevents reading SOUL.md from symlink targets
  outside personalities dir.
- POST /api/personality/set: require() only checks session_id (not name)
  so clearing with name='' works correctly instead of 400.
- POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to
  prevent unbounded context window consumption.
- POST /api/personality/set: return personality:null (not '') when cleared.
- streaming.py: same MAX_FILE_BYTES guard before prepending to system msg.

Added tests/test_sprint28.py: 11 tests for API round-trip, listing,
symlink guard, path traversal rejection, clear, size cap, persistence.
Tests pass in isolation; full-suite run has a test-isolation interaction
with shared server state across sprint tests (tracked as follow-up).

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:16:37 -07:00
Nathan Esquenazi
76cdfb69e0 fix: prefix non-default provider model IDs for correct routing (#142)
* fix: prefix non-default provider model IDs for correct routing

When multiple providers are configured, models from non-default providers
(e.g. MiniMax when Anthropic is default) were sent as bare names without
provider context. resolve_model_provider() couldn't determine the target
provider and routed them to the default provider's API, which failed.

Fix: get_available_models() now prefixes model IDs with the provider name
(e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config
provider. The default provider's models keep bare names for direct API
routing. This matches the existing pattern for OpenRouter models.

Added 2 tests to test_model_resolver.py for cross-provider routing.

Closes #138
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: model prefix — null-guard, case normalization, mutation safety, tests

Four fixes on top of original PR:
- active_provider=None guard: without a confirmed provider all models were
  being prefixed. Only prefix when active_provider is set.
- Case normalisation: compare pid against active_provider.lower() so
  config.yaml entries like 'Anthropic' match pid 'anthropic'.
- Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid];
  the default_model injector later calls list.insert() on that reference,
  permanently mutating the shared constant. Both branches now use a copy.
- Already-prefixed model IDs pass through as-is (no double-prefix).

Added 3 tests for get_available_models() prefix behaviour:
- Non-default provider models are prefixed
- Active provider's own entries remain bare
- No double-prefix when active_provider is absent

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:05:44 -07:00
Nathan Esquenazi
4622b64ca9 fix: tool call cards persist correctly after page reload (#141)
* fix: tool call cards persist correctly after page reload

Root cause: the insertion logic looked for the NEXT assistant row to
insert BEFORE, but when the triggering assistant message contained only
tool_use blocks (no text), it was filtered from the DOM by msgContent()
and no anchor row existed. Cards were silently dropped.

Fix: find the row AT the assistant_msg_idx first (exact match), fall
back to the nearest preceding visible row, then insert AFTER it (not
before the next). This handles both text+tool and tool-only assistant
messages correctly.

Closes #140
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tool card ordering — role-filter fallback anchor, preserve order for same-anchor groups

Two bugs in the initial PR:
- Fallback 'nearest row' had no role filter, could anchor to a user message
  row causing cards to appear after a user bubble. Fixed: check role==='assistant'
  in the fallback loop.
- Back-to-back tool-card groups sharing a filtered anchor were inserted in
  reverse order because each group re-read anchorRow.nextSibling from the live
  DOM. Fixed: track the last inserted node per anchor via anchorInsertAfter Map,
  so each subsequent group appends after the previous one.

Also restores the 'Collect card elements before they get moved to DOM' comment
explaining why Array.from() is used on frag before DOM insertion.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:02:25 -07:00
nesquena-hermes
89891c65c8 docs: v0.36.3 version bump and test count update (449 tests) (#137)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-06 08:21:04 -07:00
Nathan Esquenazi
173261c428 Merge pull request #136 from nesquena/docs/changelog-v0.36.3
Some checks failed
Release & Docker / release (push) Has been cancelled
docs: update CHANGELOG with v0.36.3 configurable bot name
2026-04-06 08:14:59 -07:00
Nathan Esquenazi
863dc4e938 docs: update CHANGELOG with v0.36.3 configurable bot name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:14:40 -07:00
Nathan Esquenazi
4407c3097b Merge pull request #135 from nesquena/fix/pr-131-bot-name
fix: harden bot_name feature from PR #131 — crash guard, XSS, tests
2026-04-06 08:13:49 -07:00
Nathan Esquenazi
71dd691ed0 fix: harden bot_name — crash guard, XSS escape, sanitization, tests
- Move `import html` to module top (was inside function body)
- Fix IndexError crash in /login when bot_name is empty string;
  use `or 'Hermes'` fallback instead of .get() default which
  doesn't guard against stored empty string
- Add server-side sanitization in POST /api/settings: strip + default
  empty/whitespace bot_name to 'Hermes' before persisting
- Escape _bn initial char in ui.js innerHTML (esc() consistency)
- Add maxlength=64 to #settingsBotName input field
- Add tests/test_sprint27.py: 9 tests covering API round-trip,
  empty/whitespace defaults, login page rendering, and XSS escaping
2026-04-06 15:06:16 +00:00
TaraTheStar
9f3b2e113e refactor: use template vars for login page instead of string replace 2026-04-06 14:47:00 +00:00
TaraTheStar
e8a8fceb26 feat: make bot name configurable 2026-04-06 05:14:31 +00:00
nesquena-hermes
e1c2e7e3d6 docs: fix stale 433 test counts to 440
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 14:55:04 -07:00
nesquena-hermes
c6017f461b docs: v0.36.2 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 13:59:45 -07:00
Nathan Esquenazi
e829fa50d5 fix: OpenRouter models stripped of prefix, causing 404 (#116)
When config has provider=openrouter and model=openrouter/free,
resolve_model_provider() stripped the 'openrouter/' prefix because
prefix == config_provider. This sent 'free' to OpenRouter's API,
which returned 404 (model not found).

OpenRouter always needs the full provider/model path (e.g.
openrouter/free, anthropic/claude-sonnet-4.6). The prefix-stripping
logic is only correct for direct-API providers.

Fix: skip prefix stripping entirely when config_provider is 'openrouter'.
Return the full model_id with provider='openrouter'.

Added 7 unit tests for resolve_model_provider() covering:
- openrouter/free keeps full path (the bug)
- openrouter cross-provider models keep full path
- direct API providers still strip prefix correctly
- cross-provider routing to openrouter
- bare model names use config provider
- empty model returns defaults

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:58:37 -07:00
nesquena-hermes
1777cf7bfe docs: v0.36.1 release notes and version bump
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 12:46:58 -07:00
Nathan Esquenazi
48ba2e79e2 fix: Enter key reliably submits login form (#124)
The login form used 'return doLogin(event)' in onsubmit, but doLogin is
async so it returns a Promise (truthy), which some browsers interpret as
'proceed with native form submit'. Changed to 'doLogin(event);return false'
and added an explicit onkeydown Enter handler on the password input as
belt-and-suspenders.

Closes #124

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:45:57 -07:00
Nathan Esquenazi
52e3fb70e0 Merge pull request #123 from nesquena/docs/update-parity-assessment
docs: update SPRINTS parity assessment from v0.21 to v0.36
2026-04-05 10:10:49 -07:00
Nathan Esquenazi
c1bbdf9aeb docs: update SPRINTS parity assessment from v0.21 to v0.36
CLI parity: ~90% -> ~95% (added profiles, CLI bridge, compaction,
self-update, git detection, slash commands, rAF throttle)

Claude parity: ~70% -> ~85% (added themes, voice, mobile responsive,
collapsible groups, context indicator, token display, Docker, subagent
delegation cards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:10:33 -07:00
nesquena-hermes
3ca7f08b59 docs: sweep markdown for v0.36
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-05 10:00:45 -07:00
32 changed files with 3144 additions and 296 deletions

View File

@@ -26,3 +26,6 @@
# Path to your Hermes config.yaml (for toolsets and model config)
# HERMES_CONFIG_PATH=~/.hermes/config.yaml
# Display name for the assistant in the UI (default: Hermes)
# HERMES_WEBUI_BOT_NAME=Hermes

View File

@@ -5,6 +5,209 @@
---
## [v0.40.2] — 2026-04-09
### Features
- **Full approval UI** (PR #187): When the agent triggers a dangerous command
(e.g. `rm -rf`, `pkill -9`), a polished approval card now appears immediately
instead of leaving the chat stuck in "Thinking…" forever. Four one-click buttons:
Allow once, Allow session, Always allow, Deny. Enter key defaults to Allow once.
Buttons disable immediately on click to prevent double-submit. Card auto-focuses
Allow once so keyboard-only users can approve in one keystroke. All labels and
the heading are fully i18n-translated (English + Chinese).
### Bug Fixes
- **Approval SSE event never sent** (PR #187): `register_gateway_notify()` was
never called before the agent ran, so the approval module had no way to push
the `approval` SSE event to the frontend. Fixed by registering a callback that
calls `put('approval', ...)` the instant a dangerous command is detected.
- **Agent thread never unblocked** (PR #187): `/api/approval/respond` did not call
`resolve_gateway_approval()`, so the agent thread waited for the full 5-minute
gateway timeout. Now calls it on every respond, waking the thread immediately.
- **`_unreg_notify` scoping** (PR #187): Variable was only assigned inside a `try`
block but referenced in `finally`. Initialised to `None` before the `try` so the
`finally` guard is always well-defined.
### Tests
- 32 new tests in `tests/test_sprint30.py`: approval card HTML structure, all 4
button IDs and data-i18n labels, keyboard shortcut in boot.js, i18n keys in both
locales, CSS loading/disabled/kbd states, messages.js button-disable behaviour,
streaming.py scoping, HTTP regression for all 4 choices.
- 16 tests in `tests/test_approval_unblock.py` (gateway approval unit + HTTP).
- **547 tests total** (499 → 515 → 547).
---
## [v0.40.1] — 2026-04-09
### Bug Fixes
- **Default locale on first install** (PR #185): A fresh install would start in
English based on the server default, but `loadLocale()` could resurrect a
stale or unsupported locale code from `localStorage`. Now `loadLocale()` falls
back to English when there is no saved code or the saved code is not in the
LOCALES bundle. `setLocale()` also stores the resolved code, so an unknown
input never persists to storage.
---
## [v0.40.0] — 2026-04-09
### Features
- **i18n — pluggable language switcher** (PR #179): Settings panel now has a
Language dropdown. Ships with English and Chinese (中文). All UI strings use
a `t()` helper that falls back to English for missing keys. The login page
also localises — title, placeholder, button, and error strings all respond to
the saved locale. Add a language by adding a LOCALES entry to `static/i18n.js`.
- **Notification sound + browser notifications** (PR #180): Two new settings
toggles. "Notification sound" plays a short two-tone chime when the assistant
finishes or an approval card appears. "Browser notification" fires a system
notification when the tab is in the background.
- **Thinking / reasoning block display** (PR #181, #182): Inline `<think>…</think>`
and Gemma 4 `<|channel>thought…<channel|>` tags are parsed out of assistant
messages and rendered as a collapsible 💡 "Thinking" card above the reply.
During streaming, the bubble shows "Thinking…" until the tag closes. Hardened
against partial-tag edge cases and empty thinking blocks.
### Bug Fixes
- **Stray `}` in message row HTML** (PR #183): A typo in the i18n refactor left
an extra `}` in the `msg-role` div template literal, producing `<div class="msg-role user" }>`.
Removed.
- **JS-escape login locale strings** (PR #183): `LOGIN_INVALID_PW` and
`LOGIN_CONN_FAILED` were injected into a JS string context without escaping
single quotes or backslashes. Now uses minimal JS-string escaping.
---
## [v0.39.1] — 2026-04-08
### Bug Fixes
- **_ENV_LOCK deadlock resolved.** The environment variable lock was held for
the entire duration of agent execution (including all tool calls and streaming),
blocking all concurrent requests. Now the lock is acquired only for the brief
env variable read/write operations, released before the agent runs, and
re-acquired in the finally block for restoration.
---
## [v0.39.0] — 2026-04-08
### Security (12 fixes — PR #171 by @betamod, reviewed by @nesquena-hermes)
- **CSRF protection**: all POST endpoints now validate `Origin`/`Referer` against `Host`. Non-browser clients (curl, agent) without these headers are unaffected.
- **PBKDF2 password hashing**: `save_settings()` was using single-iteration SHA-256. Now calls `auth._hash_password()` — PBKDF2-HMAC-SHA256 with 600,000 iterations and a per-installation random salt.
- **Login rate limiting**: 5 failed attempts per 60 seconds per IP returns HTTP 429.
- **Session ID validation**: `Session.load()` rejects any non-hex character before touching the filesystem, preventing path traversal via crafted session IDs.
- **SSRF DNS resolution**: `get_available_models()` resolves DNS before checking private IPs. Prevents DNS rebinding attacks. Known-local providers (Ollama, LM Studio, localhost) are whitelisted.
- **Non-loopback startup warning**: server prints a clear warning when binding to `0.0.0.0` without a password set — a common Docker footgun.
- **ENV_LOCK consistency**: `_ENV_LOCK` now wraps all `os.environ` mutations in both the sync chat and streaming restore blocks, preventing races across concurrent requests.
- **Stored XSS prevention**: files with `text/html`, `application/xhtml+xml`, or `image/svg+xml` MIME types are forced to `Content-Disposition: attachment`, preventing execution in-browser.
- **HMAC signature**: extended from 64 bits to 128 bits (16-char to 32-char hex).
- **Skills path validation**: `resolve().relative_to(SKILLS_DIR)` check added after skill directory construction to prevent traversal.
- **Secure cookie flag**: auto-set when TLS or `X-Forwarded-Proto: https` is detected. Uses `getattr` safely so plain sockets don't raise `AttributeError`.
- **Error path sanitization**: `_sanitize_error()` strips absolute filesystem paths from exception messages before they reach the client.
### Tests
- Added `tests/test_sprint29.py` — 33 tests covering all 12 security fixes.
---
## [v0.38.6] — 2026-04-07
### Fixed
- **`/insights` message count always 0 for WebUI sessions** (#163, #164): `sync_session_usage()` wrote token counts, cost, model, and title to `state.db` but never `message_count`. Both the streaming and sync chat paths now pass `len(s.messages)`. Note: `/insights` sync is opt-in — enable **Sync to Insights** in Settings (it's off by default).
---
## [v0.38.5] — 2026-04-06
### Fixed
- **Custom endpoint URL construction** (#138, #160): `base_url` ending in `/v1` was incorrectly stripped before appending `/models`, producing `http://host/models` instead of `http://host/v1/models`. Fixed to append directly.
- **`custom_providers` config entries now appear in dropdown** (#138, #160): Models defined under `config.yaml` `custom_providers` (e.g. Ollama aliases, Azure model overrides) are now always included in the dropdown, even when the `/v1/models` endpoint is unreachable.
- **Custom endpoint API key reads profile `.env`** (#138, #160): Custom endpoint auth now checks `~/.hermes/.env` keys in addition to `os.environ`.
---
## [v0.38.4] — 2026-04-06
### Fixed
- **Copilot false positive in model dropdown** (#158): `list_available_providers()` reported Copilot as available on any machine with `gh` CLI auth, because the Copilot token resolver falls back to `gh auth token`. The dropdown now skips any provider whose credential source is `'gh auth token'` — only explicit, dedicated credentials count. Users with `GITHUB_TOKEN` explicitly set in their `.env` still see Copilot correctly.
---
## [v0.38.3] — 2026-04-06
### Fixed
- **Model dropdown shows only configured providers** (#155): Provider detection now uses `hermes_cli.models.list_available_providers()` — the same auth check the Hermes agent uses at runtime — instead of scanning raw API key env vars. The dropdown now reflects exactly what the user has configured (auth.json, credential pools, OAuth flows like Copilot). When no providers are detected, shows only the configured default model rather than a full generic list. Added `copilot` and `gemini` to the curated model lists. Falls back to env var scanning for standalone installs without hermes-agent.
---
## [v0.38.2] — 2026-04-06
### Fixed
- **Tool cards actually render on page reload** (#140, #153): PR #149 fixed the wrong filter — it updated `vis` but not `visWithIdx` (the loop that actually creates DOM rows), so anchor rows were never inserted. This PR fixes `visWithIdx`. Additionally, `streaming.py`'s `assistant_msg_idx` builder previously only scanned Anthropic content-array format and produced `idx=-1` for all OpenAI-format tool calls (the format used in saved sessions); it now handles both. As a final fallback, `renderMessages()` now builds tool card data directly from per-message `tool_calls` arrays when `S.toolCalls` is empty, covering historical sessions that predate session-level tool tracking.
---
## [v0.38.1] — 2026-04-06
### Fixed
- **Model selector duplicates** (#147, #151): When `config.yaml` sets `model.default` with a provider prefix (e.g. `anthropic/claude-opus-4.6`), the model dropdown no longer shows a duplicate entry alongside the existing bare-ID entry. The dedup check now normalizes both sides before comparing.
- **Stale model labels** (#147, #151): Sessions created with models no longer in the current provider list now show `"ModelName (unavailable)"` in muted text with a tooltip, instead of appearing as a normal selectable option that would fail silently on send.
---
## [v0.38.0] — 2026-04-06
### Fixed
- **Multi-provider model routing (#138):** Non-default provider models now use `@provider:model` format. `resolve_model_provider()` routes them through `resolve_runtime_provider(requested=provider)` — no OpenRouter fallback for users with direct provider keys.
- **Personalities from config.yaml (#139):** `/api/personalities` reads from `config.yaml` `agent.personalities` (the documented mechanism). Personality prompts pass via `agent.ephemeral_system_prompt`.
- **Tool call cards survive page reload (#140):** Assistant messages with only `tool_use` content are no longer filtered from the render list, preserving anchor rows for tool card display.
---
## [v0.37.0] /personality command, model prefix routing fix, tool card reload fix
*April 6, 2026 | 465 tests*
### Features
- **`/personality` slash command.** Set a per-session agent personality from `~/.hermes/personalities/<name>/SOUL.md`. The personality prompt is prepended to the system message for every turn. Use `/personality <name>` to activate, `/personality none` to clear, `/personality` (no args) to list available personalities. Backend: `GET /api/personalities`, `POST /api/personality/set`. (PR #143)
### Bug Fixes
- **Model dropdown routes non-default provider models correctly (#138).** When the active provider is `anthropic` and you pick a `minimax` model, its ID is now prefixed `minimax/MiniMax-M2.7` so `resolve_model_provider()` can route it through OpenRouter. Guards added: `active_provider=None` prevents all-providers-prefixed, case is normalised, shared `_PROVIDER_MODELS` list is no longer mutated by the default_model injector. (PR #142)
- **Tool call cards persist correctly after page reload.** The reload rendering logic now anchors cards AFTER the triggering assistant row (not before the next one), handles multi-step chains sharing a filtered anchor in chronological order, and filters fallback anchor to assistant rows only. (PR #141)
---
## [v0.36.3] Configurable Assistant Name
*April 6, 2026 | 449 tests*
### Features
- **Configurable bot name.** New "Assistant Name" field in Settings panel.
Display name updates throughout the UI: sidebar, topbar, message roles,
login page, browser tab title, and composer placeholder. Defaults to
"Hermes". Configurable via settings or `HERMES_WEBUI_BOT_NAME` env var.
Server-side sanitization prevents empty names and escapes HTML for the
login page. (PR #135, based on #131 by @TaraTheStar)
---
## [v0.36.2] OpenRouter model routing fix
*April 5, 2026 | 440 tests*
### Bug Fixes
- **OpenRouter models sent without prefix, causing 404 (#116).** `resolve_model_provider()` was stripping the `openrouter/` prefix from model IDs (e.g. sending `free` instead of `openrouter/free`) when `config_provider == 'openrouter'`. OpenRouter requires the full `provider/model` path to route upstream correctly. Fixed with an early return that preserves the complete model ID for all OpenRouter configs. (#127)
- Added 7 unit tests for `resolve_model_provider()` — first coverage on this function. Tests the regression, cross-provider routing, direct-API prefix stripping, bare models, and empty model.
---
## [v0.36.1] Login form Enter key fix
*April 5, 2026 | 433 tests*
### Bug Fixes
- **Login form Enter key unreliable in some browsers (#124).** `onsubmit="return doLogin(event)"` returned a Promise (async functions always return a truthy Promise), which could let the browser fall through to native form submission. Fixed with `doLogin(event);return false` plus an explicit `onkeydown` Enter handler on the password input as belt-and-suspenders. (#125)
---
## [v0.36] Self-Update Checker with One-Click Update
*April 5, 2026 | 433 tests*
@@ -1269,3 +1472,6 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
---
*Last updated: v0.36, April 5, 2026 | Tests: 433*
### Markdown sweep
- ROADMAP.md, TESTING.md, SPRINTS.md, README.md, and THEMES.md refreshed to match v0.36 and 433 tests.

View File

@@ -1,6 +1,6 @@
# Hermes Web UI
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, remembers what it learns, and gets more capable the longer it runs.
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, that remembers what it learns and gets more capable the longer it runs.
Hermes WebUI is a lightweight, dark-themed web app interface in your browser for [Hermes Agent](https://hermes-agent.nousresearch.com/).
Full parity with the CLI experience - everything you can do from a terminal,
@@ -92,7 +92,16 @@ ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
## Quick start
First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/). Once installed:
First, you need to install and configure [Hermes Agent](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) on your computer or server. This includes the following steps to complete:
* [ ] Running the `curl` command to download and setup Hermes
* [ ] Configure your [LLM provider](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart#2-set-up-a-provider) with `hermes model`
* [ ] Configure yout [messaging gateways](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/) with `hermes gateway setup`
* [ ] Can start chatting with hermes on command-line with `hermes`
* [ ] Optional: [Configure your extended memory provider](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers)
* [ ] Optional: [Configure your tools](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools)
Once installed, you can now setup the web UI with:
```bash
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
@@ -100,11 +109,11 @@ cd hermes-webui
./start.sh
```
That is it. The script will:
That is it! The script will:
1. Locate your Hermes agent checkout automatically.
1. Locate your Hermes agent directory automatically.
2. Find (or create) a Python environment with the required dependencies.
3. Start the server.
3. Start the web server.
4. Print the URL (and SSH tunnel command if you are on a remote machine).
---
@@ -284,8 +293,8 @@ Or using the agent venv explicitly:
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **424 tests**
across 22 test files.
Production data and real cron jobs are never touched. Current count: **433 tests**
across 23 test files.
---

View File

@@ -3,8 +3,8 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.35 (April 5, 2026)
> Tests: 433 total (433 passing, 0 failures)
> Last updated: v0.39.0 (April 8, 2026)
> Tests: 499 total (499 passing, 0 failures)
> Source: <repo>/
---
@@ -47,6 +47,9 @@
| v0.34.2 | Theme text colors | 5 new per-theme typography variables (--strong, --em, --code-text, --code-inline-bg, --pre-text) | 433 |
| v0.34.3 | Light theme final polish | 46 light-scoped selector overrides for sidebar, roles, chips, interactive elements | 433 |
| v0.35 | Security hardening | Env race fix, random signing key, upload path traversal, PBKDF2 password hash | 433 |
| v0.36v0.37 | Model routing, personality config, tool card reload, duplicate model fixes | Model routing by provider prefix, personality via config.yaml, tool cards reload on page refresh | 466 |
| v0.38.0v0.38.6 | Model selector, custom endpoints, OLED theme, reasoning display, insights sync | Custom endpoint URL fix, OLED theme, top-level reasoning field fix, message_count sync to state.db | 466 |
| v0.39.0 | Security hardening (Sprint 29) | CSRF, PBKDF2, rate limiting, session ID validation, SSRF, ENV_LOCK, XSS, HMAC, skills traversal, secure cookie, error sanitization, startup warning | 499 |
---

View File

@@ -14,19 +14,27 @@
---
## Where we are now (v0.21)
## Where we are now (v0.36)
**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
file ops with tree view, cron/skills/memory CRUD, session management, streaming,
cancel, multi-provider models, custom endpoint discovery, slash commands,
thinking/reasoning display, password auth -- all solid. Gaps are subagent
visibility, toolset control, and code execution.
**CLI parity: ~95% complete.** Core agent loop, all tools visible, workspace
file ops with tree view and git detection, cron/skills/memory CRUD, session
management, streaming with rAF throttle, cancel, multi-provider models, custom
endpoint discovery, slash commands (help/clear/model/workspace/new/usage/theme/compact),
thinking/reasoning display, password auth, multi-profile support with seamless
switching, CLI session bridge (read and import from state.db), context
auto-compaction handling, self-update checker. Remaining gaps: subagent
session tree, toolset control per session, code execution cells.
**Claude parity: ~70% complete.** Chat, streaming, file browser, session
management, tool cards, syntax highlighting, model switching, projects,
settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
commands, thinking display, auth -- all present. Gaps are artifacts, voice,
TTS, sharing, mobile-optimized layout.
**Claude parity: ~85% complete.** Chat, streaming, file browser, session
management with projects and tags, tool cards with subagent delegation,
syntax highlighting, model switching, Mermaid diagrams, mobile responsive
layout (hamburger sidebar, bottom nav, files slide-over), breadcrumb
workspace nav with tree view, slash commands, thinking/reasoning display,
auth with signed cookies, 6 pluggable UI themes (dark/light/slate/solarized/
monokai/nord), voice input (Web Speech API), collapsible date groups,
context usage indicator, token/cost display, git branch badge, Docker
support. Remaining gaps: artifacts (HTML/SVG preview), TTS playback,
sharing/public URLs, code execution inline.
---
@@ -1155,7 +1163,8 @@ New test cases in `tests/test_sprint26.py`:
---
*Last updated: April 5, 2026*
*Current version: v0.36 | 433 tests*
*Last updated: April 8, 2026*
*Current version: v0.39.0 | 499 tests*
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
*Horizon sprint: Sprint 25 (macOS Desktop Application)*
*Docs sweep policy: update markdown proactively during PR reviews and after significant releases*

View File

@@ -1,14 +1,14 @@
# Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent.
> It covers user-facing features of the UI through Sprint 26 (v0.34.3).
> It covers user-facing features of the UI through Sprint 26 (v0.36.2) and later releases.
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
>
> Automated tests: 433 total (433 passing, 0 failures)
> Automated tests: 547 total (547 passing, 0 known isolation failures)
> Run: `pytest tests/ -v --timeout=60`
---
@@ -1708,8 +1708,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
---
*Last updated: Sprint 26 / v0.34.3, April 5, 2026*
*Total automated tests: 433 (433 passing, 0 failures)*
*Last updated: Sprint 26 / v0.36, April 5, 2026*
*Total automated tests: 440 (440 passing, 0 failures)*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

View File

@@ -1,6 +1,6 @@
# Hermes Web UI — Themes
Hermes Web UI supports pluggable color themes. Five themes ship built-in, and
Hermes Web UI supports pluggable color themes. Seven themes ship built-in, and
you can create your own with pure CSS — no Python changes needed.
---
@@ -27,6 +27,7 @@ preview is instant — the UI updates as you click through options.
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
| **OLED** | True black (#000) backgrounds for OLED displays. Minimizes glow and burn-in risk. |
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
---

View File

@@ -24,6 +24,26 @@ SESSION_TTL = 86400 # 24 hours
# Active sessions: token -> expiry timestamp
_sessions = {}
# ── Login rate limiter ──────────────────────────────────────────────────────
_login_attempts = {} # ip -> [timestamp, ...]
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_WINDOW = 60 # seconds
def _check_login_rate(ip: str) -> bool:
"""Return True if the IP is allowed to attempt login."""
now = time.time()
attempts = _login_attempts.get(ip, [])
# Prune old attempts
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
_login_attempts[ip] = attempts
return len(attempts) < _LOGIN_MAX_ATTEMPTS
def _record_login_attempt(ip: str) -> None:
now = time.time()
attempts = _login_attempts.get(ip, [])
attempts.append(now)
_login_attempts[ip] = attempts
def _signing_key():
"""Return a random signing key, generating and persisting one on first call."""
@@ -84,7 +104,7 @@ def create_session() -> str:
"""Create a new auth session. Returns signed cookie value."""
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_TTL
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
return f"{token}.{sig}"
@@ -93,7 +113,7 @@ def verify_session(cookie_value) -> bool:
if not cookie_value or '.' not in cookie_value:
return False
token, sig = cookie_value.rsplit('.', 1)
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(sig, expected_sig):
return False
expiry = _sessions.get(token)
@@ -157,6 +177,9 @@ def set_auth_cookie(handler, cookie_value) -> None:
cookie[COOKIE_NAME]['samesite'] = 'Lax'
cookie[COOKIE_NAME]['path'] = '/'
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
# Set Secure flag when connection is HTTPS
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
cookie[COOKIE_NAME]['secure'] = True
handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())

View File

@@ -296,7 +296,7 @@ _FALLBACK_MODELS = [
{'provider': 'OpenAI', 'id': 'openai/o4-mini', 'label': 'o4-mini'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-sonnet-4-5', 'label': 'Claude Sonnet 4.5'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-haiku-3-5', 'label': 'Claude Haiku 3.5'},
{'provider': 'Anthropic', 'id': 'anthropic/claude-haiku-4-5', 'label': 'Claude Haiku 4.5'},
{'provider': 'Other', 'id': 'google/gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'},
{'provider': 'Other', 'id': 'deepseek/deepseek-chat-v3-0324', 'label': 'DeepSeek V3'},
{'provider': 'Other', 'id': 'meta-llama/llama-4-scout', 'label': 'Llama 4 Scout'},
@@ -318,7 +318,7 @@ _PROVIDER_MODELS = {
{'id': 'claude-opus-4.6', 'label': 'Claude Opus 4.6'},
{'id': 'claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6'},
{'id': 'claude-sonnet-4-5', 'label': 'Claude Sonnet 4.5'},
{'id': 'claude-haiku-3-5', 'label': 'Claude Haiku 3.5'},
{'id': 'claude-haiku-4-5', 'label': 'Claude Haiku 4.5'},
],
'openai': [
{'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
@@ -363,18 +363,34 @@ _PROVIDER_MODELS = {
{'id': 'MiniMax-M2.5-highspeed', 'label': 'MiniMax M2.5 Highspeed'},
{'id': 'MiniMax-M2.1', 'label': 'MiniMax M2.1'},
],
# GitHub Copilot — model IDs served via the Copilot API
'copilot': [
{'id': 'gpt-5.4', 'label': 'GPT-5.4'},
{'id': 'gpt-5.4-mini', 'label': 'GPT-5.4 Mini'},
{'id': 'gpt-4o', 'label': 'GPT-4o'},
{'id': 'claude-opus-4.6', 'label': 'Claude Opus 4.6'},
{'id': 'claude-sonnet-4.6', 'label': 'Claude Sonnet 4.6'},
{'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'},
],
# 'gemini' is the hermes_cli provider ID for Google AI Studio
'gemini': [
{'id': 'gemini-2.5-pro', 'label': 'Gemini 2.5 Pro'},
{'id': 'gemini-2.0-flash', 'label': 'Gemini 2.0 Flash'},
],
}
def resolve_model_provider(model_id: str) -> tuple:
"""Resolve bare model name, provider, and base_url for AIAgent.
"""Resolve model name, provider, and base_url for AIAgent.
Model IDs from the dropdown may include a provider prefix
(e.g. 'anthropic/claude-sonnet-4.6'). Direct-API providers expect
bare model names, while OpenRouter expects the full provider/model path.
Model IDs from the dropdown can be in several formats:
- 'claude-sonnet-4.6' (bare name, uses config default provider)
- 'anthropic/claude-sonnet-4.6' (OpenRouter format, provider/model)
- '@minimax:MiniMax-M2.7' (explicit provider hint from dropdown)
Also reads base_url from config.yaml so providers with custom endpoints
(e.g. MiniMax, Z.AI) are routed correctly.
The @provider:model format is used for models from non-default provider
groups in the dropdown, so we can route them through the correct provider
via resolve_runtime_provider(requested=provider) instead of the default.
Returns (model, provider, base_url) where provider and base_url may be None.
"""
@@ -389,8 +405,19 @@ def resolve_model_provider(model_id: str) -> tuple:
if not model_id:
return model_id, config_provider, config_base_url
# @provider:model format — explicit provider hint from the dropdown.
# Route through that provider directly (resolve_runtime_provider will
# resolve credentials in streaming.py).
if model_id.startswith('@') and ':' in model_id:
provider_hint, bare_model = model_id[1:].split(':', 1)
return bare_model, provider_hint, None
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# OpenRouter always needs the full provider/model path (e.g. openrouter/free,
# anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter.
if config_provider == 'openrouter':
return model_id, 'openrouter', config_base_url
# If prefix matches config provider exactly, strip it and use that provider directly.
# e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
if config_provider and prefix == config_provider:
@@ -398,7 +425,6 @@ def resolve_model_provider(model_id: str) -> tuple:
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
# Never strip the prefix and try a direct-API call to a provider whose key may not exist.
if prefix in _PROVIDER_MODELS and prefix != config_provider:
return model_id, 'openrouter', None
@@ -458,50 +484,76 @@ def get_available_models() -> dict:
except Exception:
pass
# 4. Check for API keys that imply available providers
try:
from api.profiles import get_active_hermes_home as _gah2
hermes_env_path = _gah2() / '.env'
except ImportError:
hermes_env_path = HOME / '.hermes' / '.env'
env_keys = {}
if hermes_env_path.exists():
try:
for line in hermes_env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
env_keys[k.strip()] = v.strip().strip('"').strip("'")
except Exception:
pass
# Merge with actual env
all_env = {**env_keys}
for k in ('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY',
'GOOGLE_API_KEY', 'GLM_API_KEY', 'KIMI_API_KEY', 'DEEPSEEK_API_KEY'):
val = os.getenv(k)
if val:
all_env[k] = val
# 4. Detect available providers.
# Primary: ask hermes-agent's auth layer — the authoritative source. It checks
# auth.json, credential pools, and env vars the same way the agent does at runtime,
# so the dropdown reflects exactly what the user has configured.
# Fallback: scan raw API key env vars (matches old behaviour if hermes not available).
detected_providers = set()
if active_provider:
detected_providers.add(active_provider)
if all_env.get('ANTHROPIC_API_KEY'):
detected_providers.add('anthropic')
if all_env.get('OPENAI_API_KEY'):
detected_providers.add('openai')
if all_env.get('OPENROUTER_API_KEY'):
detected_providers.add('openrouter')
if all_env.get('GOOGLE_API_KEY'):
detected_providers.add('google')
if all_env.get('GLM_API_KEY'):
detected_providers.add('zai')
if all_env.get('KIMI_API_KEY'):
detected_providers.add('kimi-coding')
if all_env.get('MINIMAX_API_KEY') or all_env.get('MINIMAX_CN_API_KEY'):
detected_providers.add('minimax')
if all_env.get('DEEPSEEK_API_KEY'):
detected_providers.add('deepseek')
all_env: dict = {} # profile .env keys — populated below, used by custom endpoint auth
_hermes_auth_used = False
try:
from hermes_cli.models import list_available_providers as _lap
from hermes_cli.auth import get_auth_status as _gas
for _p in _lap():
if not _p.get('authenticated'):
continue
# Exclude providers whose credential came from an ambient token
# (e.g. 'gh auth token' for Copilot on a machine with gh CLI auth).
# Only include providers with an explicit, dedicated credential.
try:
_src = _gas(_p['id']).get('key_source', '')
if _src == 'gh auth token':
continue
except Exception:
pass
detected_providers.add(_p['id'])
_hermes_auth_used = True
except Exception:
pass
if not _hermes_auth_used:
# Fallback: scan .env and os.environ for known API key variables
try:
from api.profiles import get_active_hermes_home as _gah2
hermes_env_path = _gah2() / '.env'
except ImportError:
hermes_env_path = HOME / '.hermes' / '.env'
env_keys = {}
if hermes_env_path.exists():
try:
for line in hermes_env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
env_keys[k.strip()] = v.strip().strip('"').strip("'")
except Exception:
pass
all_env = {**env_keys}
for k in ('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY',
'GOOGLE_API_KEY', 'GLM_API_KEY', 'KIMI_API_KEY', 'DEEPSEEK_API_KEY'):
val = os.getenv(k)
if val:
all_env[k] = val
if all_env.get('ANTHROPIC_API_KEY'):
detected_providers.add('anthropic')
if all_env.get('OPENAI_API_KEY'):
detected_providers.add('openai')
if all_env.get('OPENROUTER_API_KEY'):
detected_providers.add('openrouter')
if all_env.get('GOOGLE_API_KEY'):
detected_providers.add('google')
if all_env.get('GLM_API_KEY'):
detected_providers.add('zai')
if all_env.get('KIMI_API_KEY'):
detected_providers.add('kimi-coding')
if all_env.get('MINIMAX_API_KEY') or all_env.get('MINIMAX_CN_API_KEY'):
detected_providers.add('minimax')
if all_env.get('DEEPSEEK_API_KEY'):
detected_providers.add('deepseek')
# 3. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
@@ -513,9 +565,9 @@ def get_available_models() -> dict:
# Normalize the base_url and build models endpoint
base_url = cfg_base_url.strip()
if base_url.endswith('/v1'):
endpoint_url = base_url[:-3] + '/models'
endpoint_url = base_url + '/models' # /v1/models
else:
endpoint_url = base_url + '/v1/models'
endpoint_url = base_url.rstrip('/') + '/v1/models'
# Detect provider from base_url
provider = 'custom'
@@ -535,17 +587,33 @@ def get_available_models() -> dict:
except ValueError:
pass
# Resolve API key from environment
# Resolve API key from environment (check profile .env keys too)
headers = {}
api_key_vars = ('HERMES_API_KEY', 'HERMES_OPENAI_API_KEY', 'OPENAI_API_KEY',
'LOCAL_API_KEY', 'OPENROUTER_API_KEY', 'API_KEY')
for key in api_key_vars:
api_key = os.getenv(key)
api_key = all_env.get(key) or os.getenv(key)
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
break
# Fetch model list from endpoint
# Fetch model list from endpoint (with SSRF protection)
import socket
# Resolve hostname and check against private IPs after DNS lookup
parsed_url = urlparse(endpoint_url if '://' in endpoint_url else f'http://{endpoint_url}')
if parsed_url.hostname:
try:
resolved_ips = socket.getaddrinfo(parsed_url.hostname, None)
for _, _, _, _, addr in resolved_ips:
addr_obj = ipaddress.ip_address(addr[0])
if addr_obj.is_private or addr_obj.is_loopback or addr_obj.is_link_local:
# Allow known local providers (ollama, lmstudio)
is_known_local = any(k in (parsed_url.hostname or '').lower()
for k in ('ollama', 'localhost', '127.0.0.1', 'lmstudio', 'lm-studio'))
if not is_known_local:
raise ValueError(f'SSRF: resolved hostname to private IP {addr[0]}')
except socket.gaierror:
pass # DNS resolution failed -- let urllib handle it
req = urllib.request.Request(endpoint_url, method='GET')
for k, v in headers.items():
req.add_header(k, v)
@@ -570,6 +638,22 @@ def get_available_models() -> dict:
except Exception:
pass # custom endpoint unreachable or misconfigured -- fail silently
# 3b. Include models from custom_providers config entries.
# These are explicitly configured and should always appear even when the
# /v1/models endpoint is unreachable or returns a subset.
_custom_providers_cfg = cfg.get('custom_providers', [])
if isinstance(_custom_providers_cfg, list):
_seen_custom_ids = {m['id'] for m in auto_detected_models}
for _cp in _custom_providers_cfg:
if not isinstance(_cp, dict):
continue
_cp_model = _cp.get('model', '')
if _cp_model and _cp_model not in _seen_custom_ids:
_cp_label = _cp_model.split('/')[-1] if '/' in _cp_model else _cp_model
auto_detected_models.append({'id': _cp_model, 'label': _cp_label})
_seen_custom_ids.add(_cp_model)
detected_providers.add('custom')
# 5. Build model groups
if detected_providers:
for pid in sorted(detected_providers):
@@ -581,9 +665,26 @@ def get_available_models() -> dict:
'models': [{'id': m['id'], 'label': m['label']} for m in _FALLBACK_MODELS],
})
elif pid in _PROVIDER_MODELS:
# For non-default providers, prefix model IDs with @provider:model
# so resolve_model_provider() routes through that specific provider
# via resolve_runtime_provider(requested=provider).
# The default provider's models keep bare names for direct API routing.
raw_models = _PROVIDER_MODELS[pid]
_active = (active_provider or '').lower()
if _active and pid != _active:
models = []
for m in raw_models:
mid = m['id']
# Don't double-prefix; use @provider: hint for bare names
if mid.startswith('@') or '/' in mid:
models.append({'id': mid, 'label': m['label']})
else:
models.append({'id': f'@{pid}:{mid}', 'label': m['label']})
else:
models = list(raw_models)
groups.append({
'provider': provider_name,
'models': _PROVIDER_MODELS[pid],
'models': models,
})
else:
# Unknown provider -- use auto-detected models if available,
@@ -599,21 +700,21 @@ def get_available_models() -> dict:
'models': [{'id': default_model, 'label': default_model.split('/')[-1]}],
})
else:
# No providers detected -- use fallback grouped list
by_provider = {}
for m in _FALLBACK_MODELS:
by_provider.setdefault(m['provider'], []).append(
{'id': m['id'], 'label': m['label']}
)
for provider_name, models in by_provider.items():
groups.append({'provider': provider_name, 'models': models})
# No providers detected. Show only the configured default model so the user
# can at least send messages with their current setting. Avoid showing a
# generic multi-provider list — those models wouldn't be routable anyway.
label = default_model.split('/')[-1] if '/' in default_model else default_model
groups.append({'provider': 'Default', 'models': [{'id': default_model, 'label': label}]})
# Ensure the user's configured default_model always appears in the dropdown.
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
# a custom local model, or any model.default not in _FALLBACK_MODELS).
# Normalize before comparing: strip provider prefix so 'anthropic/claude-opus-4.6'
# matches 'claude-opus-4.6' already in the list and avoids a duplicate entry.
if default_model:
all_ids = {m['id'] for g in groups for m in g.get('models', [])}
if default_model not in all_ids:
_norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid
all_ids_norm = {_norm(m['id']) for g in groups for m in g.get('models', [])}
if _norm(default_model) not in all_ids_norm:
# Determine which group to inject into
label = default_model.split('/')[-1] if '/' in default_model else default_model
injected = False
@@ -676,7 +777,11 @@ _SETTINGS_DEFAULTS = {
'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
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
'password_hash': None, # SHA-256 hash; None = auth disabled
'language': 'en', # UI locale code; must match a key in static/i18n.js LOCALES
'bot_name': os.getenv('HERMES_WEBUI_BOT_NAME', 'Hermes'), # display name for the assistant
'sound_enabled': False, # play notification sound when assistant finishes
'notifications_enabled': False, # browser notification when tab is in background
'password_hash': None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
}
def load_settings() -> dict:
@@ -695,17 +800,19 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
_SETTINGS_ENUM_VALUES = {
'send_key': {'enter', 'ctrl+enter'},
}
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'}
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates', 'sound_enabled', 'notifications_enabled'}
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
_SETTINGS_LANG_RE = __import__('re').compile(r'^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$')
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
import hashlib as _hl
current = load_settings()
# Handle _set_password: hash and store as password_hash
raw_pw = settings.pop('_set_password', None)
if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
salt = str(STATE_DIR).encode()
current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest()
# Use PBKDF2 from auth module (600k iterations) -- never raw SHA-256
from api.auth import _hash_password
current['password_hash'] = _hash_password(raw_pw.strip())
# Handle _clear_password: explicitly disable auth
if settings.pop('_clear_password', False):
current['password_hash'] = None
@@ -714,6 +821,9 @@ def save_settings(settings: dict) -> dict:
# Validate enum-constrained keys
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
continue
# Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN')
if k == 'language' and (not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)):
continue
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)

View File

@@ -18,6 +18,15 @@ def bad(handler, msg, status: int=400):
return j(handler, {'error': msg}, status=status)
def _sanitize_error(e: Exception) -> str:
"""Strip filesystem paths from exception messages before returning to client."""
import re
msg = str(e)
# Remove absolute paths (Unix and Windows)
msg = re.sub(r'(?:(?:/[a-zA-Z0-9_.-]+)+|(?:[A-Z]:\\[^\s]+))', '<path>', msg)
return msg
def safe_resolve(root: Path, requested: str) -> Path:
"""Resolve a relative path inside root, raising ValueError on traversal."""
resolved = (root / requested).resolve()

View File

@@ -40,6 +40,7 @@ class Session:
tool_calls=None, pinned: bool=False, archived: bool=False,
project_id: str=None, profile=None,
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
personality=None,
**kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title
@@ -56,6 +57,7 @@ class Session:
self.input_tokens = input_tokens or 0
self.output_tokens = output_tokens or 0
self.estimated_cost = estimated_cost
self.personality = personality
@property
def path(self):
@@ -71,6 +73,9 @@ class Session:
@classmethod
def load(cls, sid):
# Validate session ID format to prevent path traversal
if not sid or not all(c in '0123456789abcdef' for c in sid):
return None
p = SESSION_DIR / f'{sid}.json'
if not p.exists():
return None
@@ -92,6 +97,7 @@ class Session:
'input_tokens': self.input_tokens,
'output_tokens': self.output_tokens,
'estimated_cost': self.estimated_cost,
'personality': self.personality,
}
def get_session(sid):

View File

@@ -2,6 +2,7 @@
Hermes Web UI -- Route handlers for GET and POST endpoints.
Extracted from server.py (Sprint 11) so server.py is a thin shell.
"""
import html as _html
import json
import os
import queue
@@ -19,7 +20,25 @@ from api.config import (
IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES,
CHAT_LOCK, load_settings, save_settings,
)
from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_headers
from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_headers, _sanitize_error
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re
def _check_csrf(handler) -> bool:
"""Reject cross-origin POST requests. Returns True if OK."""
origin = handler.headers.get('Origin', '')
referer = handler.headers.get('Referer', '')
host = handler.headers.get('Host', '')
if not origin and not referer:
return True # non-browser clients (curl, agent) have no Origin
target = origin or referer
# Allow same-origin: Origin must match Host
if host and target:
# Extract host:port from origin/referer
m = _re.match(r'^https?://([^/]+)', target)
if m and m.group(1) == host:
return True
return False
from api.models import (
Session, get_session, new_session, all_sessions, title_from,
_write_session_index, SESSION_INDEX_FILE,
@@ -39,6 +58,7 @@ try:
has_pending, pop_pending, submit_pending,
approve_session, approve_permanent, save_permanent_allowlist,
is_approved, _pending, _lock, _permanent_approved,
resolve_gateway_approval,
)
except ImportError:
has_pending = lambda *a, **k: False
@@ -48,15 +68,34 @@ except ImportError:
approve_permanent = lambda *a, **k: None
save_permanent_allowlist = lambda *a, **k: None
is_approved = lambda *a, **k: True
resolve_gateway_approval = lambda *a, **k: 0
_pending = {}
_lock = threading.Lock()
_permanent_approved = set()
# ── Login page locale strings ─────────────────────────────────────────────────
# Add entries here to support more languages on the login page.
# The key must match the 'language' setting value (from static/i18n.js LOCALES).
_LOGIN_LOCALE = {
'en': {
'lang': 'en', 'title': 'Sign in',
'subtitle': 'Enter your password to continue',
'placeholder': 'Password', 'btn': 'Sign in',
'invalid_pw': 'Invalid password', 'conn_failed': 'Connection failed',
},
'zh': {
'lang': 'zh-CN', 'title': '\u767b\u5f55',
'subtitle': '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528',
'placeholder': '\u5bc6\u7801', 'btn': '\u767b\u5f55',
'invalid_pw': '\u5bc6\u7801\u9519\u8bef', 'conn_failed': '\u8fde\u63a5\u5931\u8d25',
},
}
# ── Login page (self-contained, no external deps) ────────────────────────────
_LOGIN_PAGE_HTML = '''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes — Sign in</title>
<html lang="{{LANG}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{BOT_NAME}} — {{LOGIN_TITLE}}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
@@ -79,12 +118,13 @@ button:hover{background:rgba(124,185,255,.25)}
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
</style></head><body>
<div class="card">
<div class="logo">H</div>
<h1>Hermes</h1>
<p class="sub">Enter your password to continue</p>
<form onsubmit="return doLogin(event)">
<input type="password" id="pw" placeholder="Password" autofocus>
<button type="submit">Sign in</button>
<div class="logo">{{BOT_NAME_INITIAL}}</div>
<h1>{{BOT_NAME}}</h1>
<p class="sub">{{LOGIN_SUBTITLE}}</p>
<form onsubmit="doLogin(event);return false">
<input type="password" id="pw" placeholder="{{LOGIN_PLACEHOLDER}}" autofocus
onkeydown="if(event.key==='Enter'){doLogin(event);event.preventDefault();}">
<button type="submit">{{LOGIN_BTN}}</button>
</form>
<div class="err" id="err"></div>
</div>
@@ -100,8 +140,8 @@ async function doLogin(e){
body:JSON.stringify({password:pw}),credentials:'include'});
const data=await res.json();
if(res.ok&&data.ok){window.location.href='/';}
else{err.textContent=data.error||'Invalid password';err.style.display='block';}
}catch(ex){err.textContent='Connection failed';err.style.display='block';}
else{err.textContent=data.error||'{{LOGIN_INVALID_PW}}';err.style.display='block';}
}catch(ex){err.textContent='{{LOGIN_CONN_FAILED}}';err.style.display='block';}
}
</script></body></html>'''
@@ -115,7 +155,23 @@ def handle_get(handler, parsed) -> bool:
content_type='text/html; charset=utf-8')
if parsed.path == '/login':
return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8')
_settings = load_settings()
_bn = _html.escape(_settings.get('bot_name') or 'Hermes')
_lang = _settings.get('language', 'en')
_login_strings = _LOGIN_LOCALE.get(_lang, _LOGIN_LOCALE['en'])
_page = (
_LOGIN_PAGE_HTML
.replace('{{BOT_NAME}}', _bn)
.replace('{{BOT_NAME_INITIAL}}', _bn[0].upper())
.replace('{{LANG}}', _html.escape(_login_strings['lang']))
.replace('{{LOGIN_TITLE}}', _html.escape(_login_strings['title']))
.replace('{{LOGIN_SUBTITLE}}', _html.escape(_login_strings['subtitle']))
.replace('{{LOGIN_PLACEHOLDER}}', _html.escape(_login_strings['placeholder']))
.replace('{{LOGIN_BTN}}', _html.escape(_login_strings['btn']))
.replace('{{LOGIN_INVALID_PW}}', _login_strings['invalid_pw'].replace('\\','\\\\').replace("'","\\'"))
.replace('{{LOGIN_CONN_FAILED}}', _login_strings['conn_failed'].replace('\\','\\\\').replace("'","\\'"))
)
return t(handler, _page, content_type='text/html; charset=utf-8')
if parsed.path == '/api/auth/status':
from api.auth import is_auth_enabled, parse_cookie, verify_session
@@ -214,6 +270,26 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == '/api/list':
return _handle_list_dir(handler, parsed)
if parsed.path == '/api/personalities':
# Read personalities from config.yaml agent.personalities section
# (matches hermes-agent CLI behavior, not filesystem SOUL.md approach)
from api.config import reload_config as _reload_cfg
_reload_cfg() # pick up config.yaml changes without server restart
from api.config import get_config as _get_cfg
_cfg = _get_cfg()
agent_cfg = _cfg.get('agent', {})
raw_personalities = agent_cfg.get('personalities', {})
personalities = []
if isinstance(raw_personalities, dict):
for name, value in raw_personalities.items():
desc = ''
if isinstance(value, dict):
desc = value.get('description', '')
elif isinstance(value, str):
desc = value[:80] + ('...' if len(value) > 80 else '')
personalities.append({'name': name, 'description': desc})
return j(handler, {'personalities': personalities})
if parsed.path == '/api/git-info':
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
@@ -336,6 +412,9 @@ def handle_get(handler, parsed) -> bool:
def handle_post(handler, parsed) -> bool:
"""Handle all POST routes. Returns True if handled, False for 404."""
# CSRF: reject cross-origin browser requests
if not _check_csrf(handler):
return j(handler, {'error': 'Cross-origin request rejected'}, status=403)
if parsed.path == '/api/upload':
return handle_upload(handler)
@@ -361,6 +440,44 @@ def handle_post(handler, parsed) -> bool:
s.save()
return j(handler, {'session': s.compact()})
if parsed.path == '/api/personality/set':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
if 'name' not in body:
return bad(handler, 'Missing required field: name')
sid = body['session_id']
name = body['name'].strip()
try:
s = get_session(sid)
except KeyError:
return bad(handler, 'Session not found', 404)
# Resolve personality from config.yaml agent.personalities section
# (matches hermes-agent CLI behavior)
prompt = ''
if name:
from api.config import reload_config as _reload_cfg2
_reload_cfg2() # pick up config changes without restart
from api.config import get_config as _get_cfg2
_cfg2 = _get_cfg2()
agent_cfg = _cfg2.get('agent', {})
raw_personalities = agent_cfg.get('personalities', {})
if not isinstance(raw_personalities, dict) or name not in raw_personalities:
return bad(handler, f'Personality "{name}" not found in config.yaml', 404)
value = raw_personalities[name]
# Resolve prompt using the same logic as hermes-agent cli.py
if isinstance(value, dict):
parts = [value.get('system_prompt', '') or value.get('prompt', '')]
if value.get('tone'):
parts.append(f'Tone: {value["tone"]}')
if value.get('style'):
parts.append(f'Style: {value["style"]}')
prompt = '\n'.join(p for p in parts if p)
else:
prompt = str(value)
s.personality = name if name else None
s.save()
return j(handler, {'ok': True, 'personality': s.personality, 'prompt': prompt})
if parsed.path == '/api/session/update':
try: require(body, 'session_id')
except ValueError as e: return bad(handler, str(e))
@@ -482,7 +599,7 @@ def handle_post(handler, parsed) -> bool:
result = switch_profile(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
return bad(handler, str(e), 404)
return bad(handler, _sanitize_error(e), 404)
except RuntimeError as e:
return bad(handler, str(e), 409)
@@ -516,12 +633,14 @@ def handle_post(handler, parsed) -> bool:
result = delete_profile_api(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
return bad(handler, str(e))
return bad(handler, _sanitize_error(e))
except RuntimeError as e:
return bad(handler, str(e), 409)
# ── Settings (POST) ──
if parsed.path == '/api/settings':
if 'bot_name' in body:
body['bot_name'] = (str(body['bot_name']) or '').strip() or 'Hermes'
saved = save_settings(body)
saved.pop('password_hash', None) # never expose hash to client
return j(handler, saved)
@@ -631,10 +750,15 @@ def handle_post(handler, parsed) -> bool:
# ── Auth endpoints (POST) ──
if parsed.path == '/api/auth/login':
from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled
from api.auth import _check_login_rate, _record_login_attempt
if not is_auth_enabled():
return j(handler, {'ok': True, 'message': 'Auth not enabled'})
client_ip = handler.client_address[0]
if not _check_login_rate(client_ip):
return j(handler, {'error': 'Too many attempts. Try again in a minute.'}, status=429)
password = body.get('password', '')
if not verify_password(password):
_record_login_attempt(client_ip)
return bad(handler, 'Invalid password', 401)
cookie_val = create_session()
handler.send_response(200)
@@ -746,7 +870,7 @@ def _handle_list_dir(handler, parsed):
'path': qs.get('path', ['.'])[0],
})
except (FileNotFoundError, ValueError) as e:
return bad(handler, str(e), 404)
return bad(handler, _sanitize_error(e), 404)
def _handle_sse_stream(handler, parsed):
@@ -795,9 +919,14 @@ def _handle_file_raw(handler, parsed):
handler.send_header('Content-Type', mime)
handler.send_header('Content-Length', str(len(raw_bytes)))
handler.send_header('Cache-Control', 'no-store')
if force_download:
# Security: force download for dangerous MIME types to prevent XSS
dangerous_types = {'text/html', 'application/xhtml+xml', 'image/svg+xml'}
if force_download or mime in dangerous_types:
handler.send_header('Content-Disposition',
f'attachment; filename="{target.name}"; filename*=UTF-8\'\'{safe_name}')
else:
handler.send_header('Content-Disposition',
f'inline; filename="{target.name}"; filename*=UTF-8\'\'{safe_name}')
handler.end_headers()
handler.wfile.write(raw_bytes)
return True
@@ -812,7 +941,7 @@ def _handle_file_read(handler, parsed):
rel = qs.get('path', [''])[0]
if not rel: return bad(handler, 'path is required')
try: return j(handler, read_file_content(Path(s.workspace), rel))
except (FileNotFoundError, ValueError) as e: return bad(handler, str(e), 404)
except (FileNotFoundError, ValueError) as e: return bad(handler, _sanitize_error(e), 404)
def _handle_approval_pending(handler, parsed):
@@ -963,12 +1092,14 @@ def _handle_chat_sync(handler, body):
if not msg: return j(handler, {'error': 'empty message'}, status=400)
workspace = Path(body.get('workspace') or s.workspace).expanduser().resolve()
s.workspace = str(workspace); s.model = body.get('model') or s.model
old_cwd = os.environ.get('TERMINAL_CWD')
os.environ['TERMINAL_CWD'] = str(workspace)
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = s.session_id
from api.streaming import _ENV_LOCK
with _ENV_LOCK:
old_cwd = os.environ.get('TERMINAL_CWD')
os.environ['TERMINAL_CWD'] = str(workspace)
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = s.session_id
try:
from run_agent import AIAgent
with CHAT_LOCK:
@@ -978,7 +1109,7 @@ def _handle_chat_sync(handler, body):
_api_key = None
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider()
_rt = resolve_runtime_provider(requested=_provider)
_api_key = _rt.get("api_key")
# Also use runtime provider/base_url if the webui config didn't resolve them
if not _provider:
@@ -1011,12 +1142,13 @@ def _handle_chat_sync(handler, body):
persist_user_message=msg,
)
finally:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
with _ENV_LOCK:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
s.messages = result.get('messages') or s.messages
s.title = title_from(s.messages, s.title); s.save()
# Sync to state.db for /insights (opt-in setting)
@@ -1030,6 +1162,7 @@ def _handle_chat_sync(handler, body):
estimated_cost=s.estimated_cost,
model=s.model,
title=s.title,
message_count=len(s.messages),
)
except Exception:
pass
@@ -1115,7 +1248,7 @@ def _handle_file_delete(handler, body):
if target.is_dir(): return bad(handler, 'Cannot delete directories via this endpoint')
target.unlink()
return j(handler, {'ok': True, 'path': body['path']})
except (ValueError, PermissionError) as e: return bad(handler, str(e))
except (ValueError, PermissionError) as e: return bad(handler, _sanitize_error(e))
def _handle_file_save(handler, body):
@@ -1129,7 +1262,7 @@ def _handle_file_save(handler, body):
if target.is_dir(): return bad(handler, 'Cannot save: path is a directory')
target.write_text(body.get('content', ''), encoding='utf-8')
return j(handler, {'ok': True, 'path': body['path'], 'size': target.stat().st_size})
except (ValueError, PermissionError) as e: return bad(handler, str(e))
except (ValueError, PermissionError) as e: return bad(handler, _sanitize_error(e))
def _handle_file_create(handler, body):
@@ -1143,7 +1276,7 @@ def _handle_file_create(handler, body):
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body.get('content', ''), encoding='utf-8')
return j(handler, {'ok': True, 'path': str(target.relative_to(Path(s.workspace)))})
except (ValueError, PermissionError) as e: return bad(handler, str(e))
except (ValueError, PermissionError) as e: return bad(handler, _sanitize_error(e))
def _handle_file_rename(handler, body):
@@ -1162,7 +1295,7 @@ def _handle_file_rename(handler, body):
source.rename(dest)
new_rel = str(dest.relative_to(Path(s.workspace)))
return j(handler, {'ok': True, 'old_path': body['path'], 'new_path': new_rel})
except (ValueError, PermissionError, OSError) as e: return bad(handler, str(e))
except (ValueError, PermissionError, OSError) as e: return bad(handler, _sanitize_error(e))
def _handle_create_dir(handler, body):
@@ -1175,7 +1308,7 @@ def _handle_create_dir(handler, body):
if target.exists(): return bad(handler, 'Path already exists')
target.mkdir(parents=True)
return j(handler, {'ok': True, 'path': str(target.relative_to(Path(s.workspace)))})
except (ValueError, PermissionError, OSError) as e: return bad(handler, str(e))
except (ValueError, PermissionError, OSError) as e: return bad(handler, _sanitize_error(e))
def _handle_workspace_add(handler, body):
@@ -1222,6 +1355,7 @@ def _handle_approval_respond(handler, body):
choice = body.get('choice', 'deny')
if choice not in ('once', 'session', 'always', 'deny'):
return bad(handler, f'Invalid choice: {choice}')
# Pop the legacy polling-mode pending entry (no-op when gateway path is active).
with _lock:
pending = _pending.pop(sid, None)
if pending:
@@ -1232,6 +1366,10 @@ def _handle_approval_respond(handler, body):
for k in keys:
approve_session(sid, k); approve_permanent(k)
save_permanent_allowlist(_permanent_approved)
# Unblock the agent thread waiting in the gateway approval queue.
# This is the primary signal when streaming is active — the agent
# thread is parked in entry.event.wait() and needs to be woken up.
resolve_gateway_approval(sid, choice, resolve_all=False)
return j(handler, {'ok': True, 'choice': choice})
@@ -1249,6 +1387,11 @@ def _handle_skill_save(handler, body):
skill_dir = SKILLS_DIR / category / skill_name
else:
skill_dir = SKILLS_DIR / skill_name
# Validate resolved path stays within SKILLS_DIR
try:
skill_dir.resolve().relative_to(SKILLS_DIR.resolve())
except ValueError:
return bad(handler, 'Invalid skill path')
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / 'SKILL.md'
skill_file.write_text(body['content'], encoding='utf-8')

View File

@@ -66,7 +66,8 @@ def sync_session_start(session_id: str, model=None) -> None:
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
estimated_cost=None, model=None, title: str=None) -> None:
estimated_cost=None, model=None, title: str=None,
message_count: int=None) -> None:
"""Update token usage and title for a WebUI session in state.db.
Called after each turn completes. Uses absolute=True to set totals
(the WebUI Session already accumulates across turns).
@@ -92,6 +93,17 @@ def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=
db.set_session_title(session_id, title)
except Exception:
pass
# Update message count
if message_count is not None:
try:
def _set_msg_count(conn):
conn.execute(
"UPDATE sessions SET message_count = ? WHERE id = ?",
(message_count, session_id),
)
db._execute_write(_set_msg_count)
except Exception:
pass
except Exception:
pass # never crash the WebUI for sync failures
finally:

View File

@@ -107,6 +107,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
HERMES_HOME=_profile_home,
)
# Still set process-level env as fallback for tools that bypass thread-local
# Acquire lock only for the env mutation, then release before the agent runs.
# The finally block re-acquires to restore — keeping critical sections short
# and preventing a deadlock where the restore would re-enter the same lock.
with _ENV_LOCK:
old_cwd = os.environ.get('TERMINAL_CWD')
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
@@ -117,8 +120,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
os.environ['HERMES_SESSION_KEY'] = session_id
if _profile_home:
os.environ['HERMES_HOME'] = _profile_home
# Lock released — agent runs without holding it
# Register a gateway-style notify callback so the approval system can
# push the `approval` SSE event the moment a dangerous command is
# detected, without waiting for the next on_tool() poll cycle.
# Without this, the agent thread blocks inside the terminal tool
# waiting for approval that the UI never knew to ask for, leaving
# the chat stuck in "Thinking…" forever.
_approval_registered = False
_unreg_notify = None
try:
from tools.approval import (
register_gateway_notify as _reg_notify,
unregister_gateway_notify as _unreg_notify,
)
def _approval_notify_cb(approval_data):
put('approval', approval_data)
_reg_notify(session_id, _approval_notify_cb)
_approval_registered = True
except ImportError:
pass # approval module not available — fall back to polling
try:
try:
def on_token(text):
if text is None:
return # end-of-stream sentinel
@@ -130,23 +153,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
for k, v in list(args.items())[:4]:
s2 = str(v); args_snap[k] = s2[:120]+('...' if len(s2)>120 else '')
put('tool', {'name': name, 'preview': preview, 'args': args_snap})
# also check for pending approval and surface it immediately
from tools.approval import has_pending as _has_pending, _pending, _lock
if _has_pending(session_id):
with _lock:
p = dict(_pending.get(session_id, {}))
if p:
put('approval', p)
# Fallback: poll for pending approval in case notify_cb wasn't
# registered (e.g. older approval module without gateway support).
try:
from tools.approval import has_pending as _has_pending, _pending, _lock
if _has_pending(session_id):
with _lock:
p = dict(_pending.get(session_id, {}))
if p:
put('approval', p)
except ImportError:
pass
if AIAgent is None:
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
# Resolve API key via Hermes runtime provider (matches gateway behaviour)
# Resolve API key via Hermes runtime provider (matches gateway behaviour).
# Pass the resolved provider so non-default providers get their own credentials.
resolved_api_key = None
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider()
_rt = resolve_runtime_provider(requested=resolved_provider)
resolved_api_key = _rt.get("api_key")
if not resolved_provider:
resolved_provider = _rt.get("provider")
@@ -205,6 +233,27 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
# Resolve personality prompt from config.yaml agent.personalities
# (matches hermes-agent CLI behavior — passes via ephemeral_system_prompt)
_personality_prompt = None
_pname = getattr(s, 'personality', None)
if _pname:
_agent_cfg = _cfg.get('agent', {})
_personalities = _agent_cfg.get('personalities', {})
if isinstance(_personalities, dict) and _pname in _personalities:
_pval = _personalities[_pname]
if isinstance(_pval, dict):
_parts = [_pval.get('system_prompt', '') or _pval.get('prompt', '')]
if _pval.get('tone'):
_parts.append(f'Tone: {_pval["tone"]}')
if _pval.get('style'):
_parts.append(f'Style: {_pval["style"]}')
_personality_prompt = '\n'.join(p for p in _parts if p)
else:
_personality_prompt = str(_pval)
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
if _personality_prompt:
agent.ephemeral_system_prompt = _personality_prompt
result = agent.run_conversation(
user_message=workspace_ctx + msg_text,
system_message=workspace_system_msg,
@@ -271,6 +320,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
for msg_idx, m in enumerate(s.messages):
if m.get('role') == 'assistant':
c = m.get('content', '')
# Anthropic format: content is a list with type=tool_use blocks
if isinstance(c, list):
for p in c:
if isinstance(p, dict) and p.get('type') == 'tool_use':
@@ -278,6 +328,22 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
pending_names[tid] = p.get('name', '')
pending_args[tid] = p.get('input', {})
pending_asst_idx[tid] = msg_idx
# OpenAI format: tool_calls as top-level field on the message
for tc in m.get('tool_calls', []):
if not isinstance(tc, dict):
continue
tid = tc.get('id', '') or tc.get('call_id', '')
fn = tc.get('function', {})
name = fn.get('name', '')
try:
import json as _j
args = _j.loads(fn.get('arguments', '{}') or '{}')
except Exception:
args = {}
if tid and name:
pending_names[tid] = name
pending_args[tid] = args
pending_asst_idx[tid] = msg_idx
elif m.get('role') == 'tool':
tid = m.get('tool_call_id') or m.get('tool_use_id', '')
name = pending_names.get(tid, '')
@@ -327,6 +393,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
estimated_cost=s.estimated_cost,
model=model,
title=s.title,
message_count=len(s.messages),
)
except Exception:
pass # never crash the stream for sync failures
@@ -338,15 +405,23 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}, 'usage': usage})
finally:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
else: os.environ['HERMES_HOME'] = old_hermes_home
finally:
# Unregister the gateway approval callback and unblock any threads
# still waiting on approval (e.g. stream cancelled mid-approval).
if _approval_registered and _unreg_notify is not None:
try:
_unreg_notify(session_id)
except Exception:
pass
with _ENV_LOCK:
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
else: os.environ['TERMINAL_CWD'] = old_cwd
if old_exec_ask is None: os.environ.pop('HERMES_EXEC_ASK', None)
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
else: os.environ['HERMES_HOME'] = old_hermes_home
except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)

View File

@@ -61,6 +61,14 @@ def main() -> None:
print_startup_config()
# Security: warn if binding non-loopback without authentication
from api.auth import is_auth_enabled
if HOST not in ('127.0.0.1', '::1', 'localhost') and not is_auth_enabled():
print(f'[!!] WARNING: Binding to {HOST} with NO PASSWORD SET.', flush=True)
print(f' Anyone on the network can access your filesystem and agent.', flush=True)
print(f' Set a password via Settings or HERMES_WEBUI_PASSWORD env var.', flush=True)
print(f' To suppress: bind to 127.0.0.1 or set a password.', flush=True)
ok, missing, errors = verify_hermes_imports()
if not ok and _HERMES_FOUND:
print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True)

View File

@@ -4,8 +4,8 @@ async function cancelStream(){
try{
await fetch(new URL(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{credentials:'include'});
const btn=$('btnCancel');if(btn)btn.style.display='none';
setStatus('Cancelling');
}catch(e){setStatus('Cancel failed: '+e.message);}
setStatus(t('cancelling'));
}catch(e){setStatus(t('cancel_failed')+e.message);}
}
// ── Mobile navigation ──────────────────────────────────────────────────────
@@ -66,7 +66,7 @@ $('btnAttach').onclick=()=>$('fileInput').click();
const recognition=new SpeechRecognition();
recognition.continuous=false;
recognition.interimResults=true;
recognition.lang='en-US';
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
let _finalText='';
let _prefix='';
@@ -108,11 +108,11 @@ $('btnAttach').onclick=()=>$('fileInput').click();
recognition.onerror=(event)=>{
_setRecording(false);
const msgs={
'not-allowed':'Microphone access denied. Check browser permissions.',
'no-speech':'No speech detected. Try again.',
'network':'Speech recognition unavailable.',
'not-allowed':t('mic_denied'),
'no-speech':t('mic_no_speech'),
'network':t('mic_network'),
};
showToast(msgs[event.error]||'Voice input error: '+event.error);
showToast(msgs[event.error]||t('mic_error')+event.error);
};
function _stopMic(){
@@ -160,10 +160,10 @@ $('importFileInput').onchange=async(e)=>{
if(res.ok&&res.session){
await loadSession(res.session.session_id);
await renderSessionList();
showToast('Session imported');
showToast(t('session_imported'));
}
}catch(err){
showToast('Import failed: '+(err.message||'Invalid JSON'));
showToast(t('import_failed')+(err.message||t('import_invalid_json')));
}
};
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
@@ -219,6 +219,17 @@ $('msg').addEventListener('keydown',e=>{
});
// B14: Cmd/Ctrl+K creates a new chat from anywhere
document.addEventListener('keydown',async e=>{
// Enter on approval card = Allow once (when a button inside the card is focused or
// card is visible and focus is not on an input/textarea/select)
if(e.key==='Enter'&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey){
const card=$('approvalCard');
const tag=(document.activeElement||{}).tagName||'';
if(card&&card.classList.contains('visible')&&tag!=='TEXTAREA'&&tag!=='INPUT'&&tag!=='SELECT'){
e.preventDefault();
if(typeof respondApproval==='function') respondApproval('once');
return;
}
}
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
e.preventDefault();
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
@@ -251,7 +262,7 @@ $('msg').addEventListener('paste',e=>{
return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type});
});
addFiles(files);
setStatus(`Image pasted: ${files.map(f=>f.name).join(', ')}`);
setStatus(t('image_pasted')+files.map(f=>f.name).join(', '));
});
document.querySelectorAll('.suggestion').forEach(btn=>{
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
@@ -306,10 +317,23 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
};
})();
function applyBotName(){
const name=window._botName||'Hermes';
document.title=name;
const sidebarH1=document.querySelector('.sidebar-header h1');
if(sidebarH1) sidebarH1.textContent=name;
const logo=document.querySelector('.sidebar-header .logo');
if(logo) logo.textContent=name.charAt(0).toUpperCase();
const topbarTitle=$('topbarTitle');
if(topbarTitle && (!S.session)) topbarTitle.textContent=name;
const msg=$('msg');
if(msg) msg.placeholder='Message '+name+'\u2026';
}
(async()=>{
// Load send key preference
let _bootSettings={};
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;_bootSettings={check_for_updates:false};}
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
// Non-blocking update check (fire-and-forget, once per tab session)
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';

View File

@@ -3,14 +3,15 @@
// (no round-trip to the agent) and shows feedback via toast or local message.
const COMMANDS=[
{name:'help', desc:'List available commands', fn:cmdHelp},
{name:'clear', desc:'Clear conversation messages', fn:cmdClear},
{name:'compact', desc:'Compress conversation context', fn:cmdCompact},
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
{name:'new', desc:'Start a new chat session', fn:cmdNew},
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
{name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'},
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear},
{name:'compact', desc:t('cmd_compact'), fn:cmdCompact},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'},
{name:'new', desc:t('cmd_new'), fn:cmdNew},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'},
];
function parseCommand(text){
@@ -42,10 +43,10 @@ function cmdHelp(){
const usage=c.arg?` <${c.arg}>`:'';
return ` /${c.name}${usage}${c.desc}`;
});
const msg={role:'assistant',content:'**Available commands:**\n'+lines.join('\n')};
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
S.messages.push(msg);
renderMessages();
showToast('Type / to see commands');
showToast(t('type_slash'));
}
function cmdClear(){
@@ -54,11 +55,11 @@ function cmdClear(){
clearLiveToolCards();
renderMessages();
$('emptyState').style.display='';
showToast('Conversation cleared');
showToast(t('conversation_cleared'));
}
async function cmdModel(args){
if(!args){showToast('Usage: /model <name>');return;}
if(!args){showToast(t('model_usage'));return;}
const sel=$('modelSelect');
if(!sel)return;
const q=args.toLowerCase();
@@ -69,36 +70,36 @@ async function cmdModel(args){
match=opt.value;break;
}
}
if(!match){showToast(`No model matching "${args}"`);return;}
if(!match){showToast(t('no_model_match')+`"${args}"`);return;}
sel.value=match;
await sel.onchange();
showToast(`Switched to ${match}`);
showToast(t('switched_to')+match);
}
async function cmdWorkspace(args){
if(!args){showToast('Usage: /workspace <name>');return;}
if(!args){showToast(t('workspace_usage'));return;}
try{
const data=await api('/api/workspaces');
const q=args.toLowerCase();
const ws=(data.workspaces||[]).find(w=>
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
);
if(!ws){showToast(`No workspace matching "${args}"`);return;}
if(!ws){showToast(t('no_workspace_match')+`"${args}"`);return;}
if(!S.session)return;
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,workspace:ws.path,model:S.session.model
})});
S.session.workspace=ws.path;
syncTopbar();await loadDir('.');
showToast(`Switched to workspace: ${ws.name||ws.path}`);
}catch(e){showToast('Workspace switch failed: '+e.message);}
showToast(t('switched_workspace')+(ws.name||ws.path));
}catch(e){showToast(t('workspace_switch_failed')+e.message);}
}
async function cmdNew(){
await newSession();
await renderSessionList();
$('msg').focus();
showToast('New session created');
showToast(t('new_session'));
}
function cmdCompact(){
@@ -107,7 +108,7 @@ function cmdCompact(){
// We send a user message so it appears in the conversation.
$('msg').value='Please compress and summarize the conversation context to free up space.';
send();
showToast('Requesting context compression...');
showToast(t('compressing'));
}
async function cmdUsage(){
@@ -120,23 +121,53 @@ async function cmdUsage(){
const cb=$('settingsShowTokenUsage');
if(cb) cb.checked=next;
renderMessages();
showToast('Token usage '+(next?'on':'off'));
showToast(next?t('token_usage_on'):t('token_usage_off'));
}
async function cmdTheme(args){
const themes=['dark','light','slate','solarized','monokai','nord'];
const themes=['dark','light','slate','solarized','monokai','nord','oled'];
if(!args||!themes.includes(args.toLowerCase())){
showToast('Usage: /theme '+themes.join('|'));
showToast(t('theme_usage')+themes.join('|'));
return;
}
const t=args.toLowerCase();
document.documentElement.dataset.theme=t;
localStorage.setItem('hermes-theme',t);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:t})});}catch(e){}
const themeName=args.toLowerCase();
document.documentElement.dataset.theme=themeName;
localStorage.setItem('hermes-theme',themeName);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
// Update settings dropdown if panel is open
const sel=$('settingsTheme');
if(sel)sel.value=t;
showToast('Theme: '+t);
if(sel)sel.value=themeName;
showToast(t('theme_set')+themeName);
}
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
// List available personalities
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast(t('no_personalities'));
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+t('personality_switch_hint')});
renderMessages();
}catch(e){showToast(t('personalities_load_failed'));}
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast(t('personality_cleared'));
}catch(e){showToast(t('failed_colon')+e.message);}
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
showToast(t('personality_set')+name);
}catch(e){showToast(t('failed_colon')+e.message);}
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────

327
static/i18n.js Normal file
View File

@@ -0,0 +1,327 @@
// ── i18n: locale bundles and t() helper ──────────────────────────────────────
// To add a new language: add an entry to LOCALES below with all keys translated.
// The language code must match a valid BCP 47 tag (used for speech recognition).
// Keys missing in a non-English locale fall back to English automatically.
const LOCALES = {
en: {
_lang: 'en',
_label: 'English',
_speech: 'en-US',
// boot.js
cancelling: 'Cancelling\u2026',
cancel_failed: 'Cancel failed: ',
mic_denied: 'Microphone access denied. Check browser permissions.',
mic_no_speech: 'No speech detected. Try again.',
mic_network: 'Speech recognition unavailable.',
mic_error: 'Voice input error: ',
session_imported: 'Session imported',
import_failed: 'Import failed: ',
import_invalid_json: 'Invalid JSON',
image_pasted: 'Image pasted: ',
// messages.js
edit_message: 'Edit message',
regenerate: 'Regenerate response',
copy: 'Copy',
copied: 'Copied!',
you: 'You',
thinking: 'Thinking',
expand_all: 'Expand all',
collapse_all: 'Collapse all',
edit_failed: 'Edit failed: ',
regen_failed: 'Regenerate failed: ',
reconnect_active: 'A response is still being generated. Reload when ready?',
reconnect_finished: 'A response was in progress when you last left. Messages may have updated.',
// approval card
approval_heading: 'Approval required',
approval_desc_prefix: 'Dangerous command detected',
approval_btn_once: 'Allow once',
approval_btn_once_title: 'Allow this one command (Enter)',
approval_btn_session: 'Allow session',
approval_btn_session_title: 'Allow for this conversation session',
approval_btn_always: 'Always allow',
approval_btn_always_title: 'Always allow this command pattern',
approval_btn_deny: 'Deny',
approval_btn_deny_title: 'Deny — do not run this command',
approval_responding: 'Responding\u2026',
untitled: 'Untitled',
n_messages: (n) => `${n} messages`,
model_unavailable: ' (unavailable)',
model_unavailable_title: 'This model is no longer in your current provider list',
// commands.js
cmd_help: 'List available commands',
cmd_clear: 'Clear conversation messages',
cmd_compact: 'Compress conversation context',
cmd_model: 'Switch model (e.g. /model gpt-4o)',
cmd_workspace: 'Switch workspace by name',
cmd_new: 'Start a new chat session',
cmd_usage: 'Toggle token usage display on/off',
cmd_theme: 'Switch theme (dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Switch agent personality',
available_commands: 'Available commands:',
type_slash: 'Type / to see commands',
conversation_cleared: 'Conversation cleared',
model_usage: 'Usage: /model <name>',
no_model_match: 'No model matching "',
switched_to: 'Switched to ',
workspace_usage: 'Usage: /workspace <name>',
no_workspace_match: 'No workspace matching "',
switched_workspace: 'Switched to workspace: ',
workspace_switch_failed: 'Workspace switch failed: ',
new_session: 'New session created',
compressing: 'Requesting context compression...',
token_usage_on: 'Token usage on',
token_usage_off: 'Token usage off',
theme_usage: 'Usage: /theme ',
theme_set: 'Theme: ',
no_active_session: 'No active session',
no_personalities: 'No personalities found (add them to ~/.hermes/personalities/)',
available_personalities: 'Available personalities:',
personality_switch_hint: '\n\nUse `/personality <name>` to switch, or `/personality none` to clear.',
personalities_load_failed: 'Failed to load personalities',
personality_cleared: 'Personality cleared',
personality_set: 'Personality: ',
failed_colon: 'Failed: ',
// ui.js
no_workspace: 'No workspace',
// workspace.js
unsaved_confirm: 'You have unsaved changes in the preview. Discard and navigate?',
save: 'Save',
edit: 'Edit',
save_title: 'Save changes',
edit_title: 'Edit this file',
saved: 'Saved',
save_failed: 'Save failed: ',
image_load_failed: 'Could not load image',
file_open_failed: 'Could not open file',
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
delete_title: 'Delete',
delete_confirm: (name) => `Delete ${name}?`,
deleted: 'Deleted ',
delete_failed: 'Delete failed: ',
new_file_prompt: 'New file name (e.g. notes.md):',
created: 'Created ',
create_failed: 'Create failed: ',
new_folder_prompt: 'New folder name:',
folder_created: 'Created folder ',
folder_create_failed: 'Create folder failed: ',
remove_title: 'Remove',
empty_dir: '(empty)',
upload_failed: 'Upload failed: ',
all_uploads_failed: (n) => `All ${n} upload(s) failed`,
// settings panel
settings_title: 'Settings',
settings_save_btn: 'Save Settings',
settings_label_model: 'Default Model',
settings_label_send_key: 'Send Key',
settings_label_theme: 'Theme',
settings_label_language: 'Language',
settings_label_token_usage: 'Show token usage',
settings_label_cli_sessions: 'Show CLI sessions',
settings_label_sync_insights: 'Sync to insights',
settings_label_check_updates: 'Check for updates',
settings_label_bot_name: 'Assistant Name',
settings_label_password: 'Access Password',
settings_saved: 'Settings saved',
settings_save_failed: 'Save failed: ',
settings_load_failed: 'Failed to load settings: ',
settings_saved_pw: 'Settings saved (password set \u2014 login now required)',
// login page (used server-side via /api/i18n/login endpoint)
login_title: 'Sign in',
login_subtitle: 'Enter your password to continue',
login_placeholder: 'Password',
login_btn: 'Sign in',
login_invalid_pw: 'Invalid password',
login_conn_failed: 'Connection failed',
},
zh: {
_lang: 'zh',
_label: '\u4e2d\u6587',
_speech: 'zh-CN',
// boot.js
cancelling: '\u6b63\u5728\u53d6\u6d88...',
cancel_failed: '\u53d6\u6d88\u5931\u8d25\uff1a',
mic_denied: '\u9ea6\u514b\u98ce\u8bbf\u95ee\u88ab\u62d2\u7edd\uff0c\u8bf7\u68c0\u67e5\u6d4f\u89c8\u5668\u6743\u9650\u3002',
mic_no_speech: '\u6ca1\u6709\u68c0\u6d4b\u5230\u8bed\u97f3\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002',
mic_network: '\u8bed\u97f3\u8bc6\u522b\u5f53\u524d\u4e0d\u53ef\u7528\u3002',
mic_error: '\u8bed\u97f3\u8f93\u5165\u51fa\u9519\uff1a',
session_imported: '\u4f1a\u8bdd\u5df2\u5bfc\u5165',
import_failed: '\u5bfc\u5165\u5931\u8d25\uff1a',
import_invalid_json: 'JSON \u65e0\u6548',
image_pasted: '\u5df2\u7c98\u8d34\u56fe\u7247\uff1a',
// messages.js
edit_message: '\u7f16\u8f91\u6d88\u606f',
regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u590d',
copy: '\u590d\u5236',
copied: '\u5df2\u590d\u5236',
you: '\u4f60',
thinking: '\u601d\u8003\u8fc7\u7a0b',
expand_all: '\u5168\u90e8\u5c55\u5f00',
collapse_all: '\u5168\u90e8\u6298\u53e0',
edit_failed: '\u7f16\u8f91\u5931\u8d25\uff1a',
regen_failed: '\u91cd\u65b0\u751f\u6210\u5931\u8d25\uff1a',
reconnect_active: '\u56de\u590d\u4ecd\u5728\u751f\u6210\u4e2d\uff0c\u51c6\u5907\u597d\u540e\u8981\u91cd\u65b0\u52a0\u8f7d\u5417\uff1f',
reconnect_finished: '\u4f60\u79bb\u5f00\u65f6\u6709\u56de\u590d\u6b63\u5728\u751f\u6210\uff0c\u6d88\u606f\u5185\u5bb9\u53ef\u80fd\u5df2\u7ecf\u66f4\u65b0\u3002',
// approval card
approval_heading: '需要审批',
approval_desc_prefix: '检测到危险命令',
approval_btn_once: '允许一次',
approval_btn_once_title: '允许执行此命令一次Enter',
approval_btn_session: '本次允许',
approval_btn_session_title: '本次会话期间允许',
approval_btn_always: '始终允许',
approval_btn_always_title: '始终允许此命令模式',
approval_btn_deny: '拒绝',
approval_btn_deny_title: '拒绝 — 不执行此命令',
approval_responding: '处理中…',
untitled: '\u672a\u547d\u540d',
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d',
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
cmd_compact: '\u538b\u7f29\u5bf9\u8bdd\u4e0a\u4e0b\u6587',
cmd_model: '\u5207\u6362\u6a21\u578b\uff08\u4f8b\u5982 /model gpt-4o\uff09',
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
type_slash: '\u8f93\u5165 / \u53ef\u67e5\u770b\u547d\u4ee4',
conversation_cleared: '\u5bf9\u8bdd\u5df2\u6e05\u7a7a',
model_usage: '\u7528\u6cd5\uff1a/model <name>',
no_model_match: '\u6ca1\u6709\u5339\u914d\u201c',
switched_to: '\u5df2\u5207\u6362\u5230 ',
workspace_usage: '\u7528\u6cd5\uff1a/workspace <name>',
no_workspace_match: '\u6ca1\u6709\u5339\u914d\u201c',
switched_workspace: '\u5df2\u5207\u6362\u5de5\u4f5c\u533a\uff1a',
workspace_switch_failed: '\u5de5\u4f5c\u533a\u5207\u6362\u5931\u8d25\uff1a',
new_session: '\u5df2\u65b0\u5efa\u4f1a\u8bdd',
compressing: '\u6b63\u5728\u8bf7\u6c42\u538b\u7f29\u4e0a\u4e0b\u6587...',
token_usage_on: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5f00\u542f',
token_usage_off: 'Token \u7528\u91cf\u663e\u793a\u5df2\u5173\u95ed',
theme_usage: '\u7528\u6cd5\uff1a/theme ',
theme_set: '\u4e3b\u9898\uff1a',
no_active_session: '\u5f53\u524d\u6ca1\u6709\u6d3b\u52a8\u4f1a\u8bdd',
no_personalities: '\u6ca1\u6709\u627e\u5230\u4eba\u8bbe\uff08\u53ef\u6dfb\u52a0\u5230 ~/.hermes/personalities/\uff09',
available_personalities: '\u53ef\u7528\u4eba\u8bbe\uff1a',
personality_switch_hint: '\n\n\u4f7f\u7528 `/personality <name>` \u5207\u6362\uff0c\u6216\u7528 `/personality none` \u6e05\u7a7a\u3002',
personalities_load_failed: '\u52a0\u8f7d\u4eba\u8bbe\u5931\u8d25',
personality_cleared: '\u4eba\u8bbe\u5df2\u6e05\u7a7a',
personality_set: '\u5f53\u524d\u4eba\u8bbe\uff1a',
failed_colon: '\u5931\u8d25\uff1a',
// ui.js
no_workspace: '\u672a\u9009\u62e9\u5de5\u4f5c\u533a',
// workspace.js
unsaved_confirm: '\u9884\u89c8\u533a\u6709\u672a\u4fdd\u5b58\u4fee\u6539\uff0c\u8981\u653e\u5f03\u66f4\u6539\u5e76\u7ee7\u7eed\u8df3\u8f6c\u5417\uff1f',
save: '\u4fdd\u5b58',
edit: '\u7f16\u8f91',
save_title: '\u4fdd\u5b58\u4fee\u6539',
edit_title: '\u7f16\u8f91\u6b64\u6587\u4ef6',
saved: '\u5df2\u4fdd\u5b58',
save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a',
image_load_failed: '\u56fe\u7247\u52a0\u8f7d\u5931\u8d25',
file_open_failed: '\u65e0\u6cd5\u6253\u5f00\u6587\u4ef6',
downloading: (name) => `\u6b63\u5728\u4e0b\u8f7d ${name}...`,
double_click_rename: '\u53cc\u51fb\u91cd\u547d\u540d',
renamed_to: '\u5df2\u91cd\u547d\u540d\u4e3a ',
rename_failed: '\u91cd\u547d\u540d\u5931\u8d25\uff1a',
delete_title: '\u5220\u9664',
delete_confirm: (name) => `\u8981\u5220\u9664 ${name} \u5417\uff1f`,
deleted: '\u5df2\u5220\u9664 ',
delete_failed: '\u5220\u9664\u5931\u8d25\uff1a',
new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a',
created: '\u5df2\u521b\u5efa ',
create_failed: '\u521b\u5efa\u5931\u8d25\uff1a',
new_folder_prompt: '\u65b0\u6587\u4ef6\u5939\u540d\u79f0\uff1a',
folder_created: '\u5df2\u521b\u5efa\u6587\u4ef6\u5939 ',
folder_create_failed: '\u521b\u5efa\u6587\u4ef6\u5939\u5931\u8d25\uff1a',
remove_title: '\u79fb\u9664',
empty_dir: '(\u7a7a)',
upload_failed: '\u4e0a\u4f20\u5931\u8d25\uff1a',
all_uploads_failed: (n) => `${n} \u4e2a\u6587\u4ef6\u5168\u90e8\u4e0a\u4f20\u5931\u8d25`,
// settings panel
settings_title: '\u8bbe\u7f6e',
settings_save_btn: '\u4fdd\u5b58\u8bbe\u7f6e',
settings_label_model: '\u9ed8\u8ba4\u6a21\u578b',
settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e',
settings_label_theme: '\u4e3b\u9898',
settings_label_language: '\u8bed\u8a00',
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd',
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0',
settings_label_bot_name: '\u52a9\u624b\u540d\u79f0',
settings_label_password: '\u8bbf\u95ee\u5bc6\u7801',
settings_saved: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58',
settings_save_failed: '\u4fdd\u5b58\u5931\u8d25\uff1a',
settings_load_failed: '\u8bbe\u7f6e\u52a0\u8f7d\u5931\u8d25\uff1a',
settings_saved_pw: '\u8bbe\u7f6e\u5df2\u4fdd\u5b58\uff08\u5bc6\u7801\u5df2\u8bbe\u7f6e\u2014\u73b0\u5728\u9700\u8981\u767b\u5f55\uff09',
// login page
login_title: '\u767b\u5f55',
login_subtitle: '\u8f93\u5165\u5bc6\u7801\u7ee7\u7eed\u4f7f\u7528',
login_placeholder: '\u5bc6\u7801',
login_btn: '\u767b\u5f55',
login_invalid_pw: '\u5bc6\u7801\u9519\u8bef',
login_conn_failed: '\u8fde\u63a5\u5931\u8d25',
},
};
// Active locale — defaults to English; overridden by loadLocale() at boot.
let _locale = LOCALES.en;
/**
* Translate a key. Falls back to English if the key is missing in the active locale.
* Supports function values (for interpolated strings): call t('key', arg).
* @param {string} key
* @param {...*} args - forwarded to function-valued translations
* @returns {string}
*/
function t(key, ...args) {
const val = _locale[key] ?? LOCALES.en[key];
if (val === undefined) return key; // final fallback: return key itself
return typeof val === 'function' ? val(...args) : val;
}
/**
* Switch locale by language code (e.g. 'en', 'zh').
* Persists to localStorage and updates the <html lang> attribute.
* @param {string} lang
*/
function setLocale(lang) {
const resolved = LOCALES[lang] ? lang : 'en';
_locale = LOCALES[resolved];
localStorage.setItem('hermes-lang', resolved);
document.documentElement.lang = _locale._speech || resolved;
}
/**
* Load locale from localStorage (called once at boot, before DOMContentLoaded).
* Server-persisted preference is applied later in loadSettingsPanel().
*/
function loadLocale() {
const saved = localStorage.getItem('hermes-lang');
setLocale(saved && LOCALES[saved] ? saved : 'en');
}
/**
* Re-stamp all [data-i18n] elements in the DOM with the current locale.
* Safe to call at any time — missing keys fall back to English.
* Call after setLocale() to make static HTML text update without a reload.
*/
function applyLocaleToDOM() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const val = t(key);
if (val && val !== key) el.textContent = val;
});
}
// Apply saved locale immediately so there's no flash of English on reload.
loadLocale();

View File

@@ -14,7 +14,7 @@
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.36</div></div></div>
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.40.2</div></div></div>
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -217,19 +217,32 @@
<button class="reconnect-btn" onclick="refreshSession()">&#8635; Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard">
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Dangerous command — approval required
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-btns">
<button class="approval-btn once" onclick="respondApproval('once')">&#10003; Allow once</button>
<button class="approval-btn session" onclick="respondApproval('session')">&#128274; Allow this session</button>
<button class="approval-btn always" onclick="respondApproval('always')">&#9734; Always allow</button>
<button class="approval-btn deny" onclick="respondApproval('deny')">&#10005; Deny</button>
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon">&#10003;</span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon">&#128274;</span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon">&#9734;</span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon">&#10005;</span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
@@ -318,23 +331,23 @@
<div class="settings-overlay" id="settingsOverlay" style="display:none">
<div class="settings-panel">
<div class="settings-header">
<h3 style="margin:0;font-size:16px">Settings</h3>
<h3 style="margin:0;font-size:16px" data-i18n="settings_title">Settings</h3>
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close">&#10005;</button>
</div>
<div class="settings-body">
<div class="settings-field">
<label for="settingsModel">Default Model</label>
<label for="settingsModel" data-i18n="settings_label_model">Default Model</label>
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label for="settingsSendKey">Send Key</label>
<label for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="enter">Enter (Shift+Enter for newline)</option>
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme">Theme</label>
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
@@ -342,42 +355,66 @@
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSoundEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
Notification sound
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Play a sound when the assistant finishes a response.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
Browser notifications
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a system notification when a response completes while the tab is in the background.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
Show token usage after responses
<span data-i18n="settings_label_token_usage">Show token usage after responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
Show CLI sessions in sidebar
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsSyncInsights" style="width:15px;height:15px;accent-color:var(--accent)">
Sync usage to /insights
<span data-i18n="settings_label_sync_insights">Sync usage to /insights</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Mirrors WebUI token usage to state.db so <code>hermes /insights</code> includes browser session data. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsCheckUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
Check for updates
<span data-i18n="settings_label_check_updates">Check for updates</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label for="settingsBotName" data-i18n="settings_label_bot_name">Assistant Name</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Display name for the assistant throughout the UI. Defaults to Hermes.</div>
<input type="text" id="settingsBotName" placeholder="Hermes" maxlength="64" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword">Access Password</label>
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
</div>
@@ -407,6 +444,7 @@
</button>
</nav>
<div class="toast" id="toast"></div>
<script src="/static/i18n.js"></script>
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>

View File

@@ -84,6 +84,11 @@ async function send(){
let assistantText='';
let assistantRow=null;
let assistantBody=null;
// Thinking tag patterns for streaming display
const _thinkPairs=[
{open:'<think>',close:'</think>'},
{open:'<|channel>thought\n',close:'<channel|>'}
];
function ensureAssistantRow(){
if(assistantRow)return;
@@ -93,8 +98,9 @@ async function send(){
assistantRow=document.createElement('div');assistantRow.className='msg-row';
assistantBody=document.createElement('div');assistantBody.className='msg-body';
const role=document.createElement('div');role.className='msg-role assistant';
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H';
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes';
const _bn=window._botName||'Hermes';
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase();
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn;
role.appendChild(icon);role.appendChild(lbl);
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
$('msgInner').appendChild(assistantRow);
@@ -105,12 +111,36 @@ async function send(){
// rAF-throttled rendering: buffer tokens, render at most once per frame
let _renderPending=false;
// Extract display text from assistantText, stripping completed thinking blocks
// and hiding content still inside an open thinking block.
function _streamDisplay(){
const raw=assistantText;
for(const {open,close} of _thinkPairs){
if(raw.startsWith(open)){
const ci=raw.indexOf(close,open.length);
if(ci!==-1){
// Thinking block complete — strip it, show the rest
return raw.slice(ci+close.length).replace(/^\s+/,'');
}
// Still inside thinking block — show placeholder
return '';
}
// Hide partial tag prefixes while streaming so users don't see
// `<thi`, `<think`, etc. before the model finishes the token.
if(open.startsWith(raw)) return '';
}
return raw;
}
function _scheduleRender(){
if(_renderPending) return;
_renderPending=true;
requestAnimationFrame(()=>{
_renderPending=false;
if(assistantBody) assistantBody.innerHTML=renderMd(assistantText);
if(assistantBody){
const txt=_streamDisplay();
const isThinking=!txt&&assistantText.length>0;
assistantBody.innerHTML=txt?renderMd(txt):(isThinking?'<span style="color:var(--muted);font-size:13px">Thinking\u2026</span>':'');
}
scrollIfPinned();
});
}
@@ -142,6 +172,8 @@ async function send(){
const d=JSON.parse(e.data);
d._session_id=activeSid;
showApprovalCard(d);
playNotificationSound();
sendBrowserNotification('Approval required',d.description||'Tool approval needed');
});
source.addEventListener('done',e=>{
@@ -175,6 +207,8 @@ async function send(){
syncTopbar();renderMessages();loadDir('.');
}
renderSessionList();setBusy(false);setStatus('');
playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
});
source.addEventListener('compressed',e=>{
@@ -321,25 +355,40 @@ function hideApprovalCard() {
let _approvalSessionId = null;
function showApprovalCard(pending) {
$("approvalDesc").textContent = pending.description || "";
$("approvalCmd").textContent = pending.command || "";
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
$("approvalDesc").textContent = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
$("approvalDesc").textContent = desc;
$("approvalCmd").textContent = pending.command || "";
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
$("approvalCard").classList.add("visible");
// Re-enable buttons in case a previous approval disabled them
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
});
const card = $("approvalCard");
card.classList.add("visible");
// Apply current locale to data-i18n elements inside the card
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
// Focus Allow once button so Enter works immediately
const onceBtn = $("approvalBtnOnce");
if (onceBtn) setTimeout(() => onceBtn.focus(), 50);
}
async function respondApproval(choice) {
const sid = _approvalSessionId || (S.session && S.session.session_id);
if (!sid) return;
hideApprovalCard();
// Disable all buttons immediately to prevent double-submit
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
const b = $(id);
if (b) { b.disabled = true; if (b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) b.classList.add("loading"); }
});
_approvalSessionId = null;
hideApprovalCard();
try {
await api("/api/approval/respond", {
method: "POST",
body: JSON.stringify({ session_id: sid, choice })
});
} catch(e) { setStatus("Approval error: " + e.message); }
} catch(e) { setStatus(t("approval_responding") + " " + e.message); }
}
function startApprovalPolling(sid) {
@@ -359,5 +408,37 @@ function startApprovalPolling(sid) {
function stopApprovalPolling() {
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
}
// ── Notifications and Sound ──────────────────────────────────────────────────
function playNotificationSound(){
if(!window._soundEnabled) return;
try{
const ctx=new (window.AudioContext||window.webkitAudioContext)();
const osc=ctx.createOscillator();
const gain=ctx.createGain();
osc.connect(gain);gain.connect(ctx.destination);
osc.type='sine';osc.frequency.setValueAtTime(660,ctx.currentTime);
osc.frequency.setValueAtTime(880,ctx.currentTime+0.1);
gain.gain.setValueAtTime(0.3,ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01,ctx.currentTime+0.3);
osc.start(ctx.currentTime);osc.stop(ctx.currentTime+0.3);
osc.onended=()=>ctx.close();
}catch(e){console.warn('Notification sound failed:',e);}
}
function sendBrowserNotification(title,body){
if(!window._notificationsEnabled||!document.hidden) return;
if(!('Notification' in window)) return;
const botName=window._botName||'Hermes';
if(Notification.permission==='granted'){
new Notification(title||botName,{body:body});
}else if(Notification.permission!=='denied'){
Notification.requestPermission().then(p=>{
if(p==='granted') new Notification(title||botName,{body:body});
});
}
}
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──

View File

@@ -975,6 +975,8 @@ function _markSettingsDirty(){
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
// Apply server-persisted locale immediately (overrides localStorage boot default)
if(settings.language && typeof setLocale==='function') setLocale(settings.language);
// Populate model dropdown from /api/models
const modelSel=$('settingsModel');
if(modelSel){
@@ -1001,6 +1003,20 @@ async function loadSettingsPanel(){
// Theme preference
const themeSel=$('settingsTheme');
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
// Language preference — populate from LOCALES bundle
const langSel=$('settingsLanguage');
if(langSel){
langSel.innerHTML='';
if(typeof LOCALES!=='undefined'){
for(const [code,bundle] of Object.entries(LOCALES)){
const opt=document.createElement('option');
opt.value=code;opt.textContent=bundle._label||code;
langSel.appendChild(opt);
}
}
langSel.value=settings.language||'en';
langSel.addEventListener('change',_markSettingsDirty,{once:false});
}
const showUsageCb=$('settingsShowTokenUsage');
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
const showCliCb=$('settingsShowCliSessions');
@@ -1009,6 +1025,13 @@ async function loadSettingsPanel(){
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
const soundCb=$('settingsSoundEnabled');
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
const notifCb=$('settingsNotificationsEnabled');
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
// Bot name
const botNameField=$('settingsBotName');
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
// Password field: always blank (we don't send hash back)
const pwField=$('settingsPassword');
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
@@ -1022,7 +1045,7 @@ async function loadSettingsPanel(){
if(disableBtn) disableBtn.style.display=active?'':'none';
}catch(e){}
}catch(e){
showToast('Failed to load settings: '+e.message);
showToast(t('settings_load_failed')+e.message);
}
}
@@ -1033,22 +1056,32 @@ async function saveSettings(andClose){
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
const language=($('settingsLanguage')||{}).value||'en';
const body={};
if(model) body.default_model=model;
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
const botName=(($('settingsBotName')||{}).value||'').trim();
body.bot_name=botName||'Hermes';
// Password: only act if the field has content; blank = leave auth unchanged
if(pw && pw.trim()){
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
showToast('Settings saved (password set — login now required)');
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
showToast(t('settings_saved_pw'));
_settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
$('settingsOverlay').style.display='none';
@@ -1060,14 +1093,21 @@ async function saveSettings(andClose){
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
window._soundEnabled=body.sound_enabled;
window._notificationsEnabled=body.notifications_enabled;
window._botName=body.bot_name;
if(typeof applyBotName==='function') applyBotName();
if(typeof setLocale==='function') setLocale(language);
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
_settingsDirty=false; _settingsThemeOnOpen=theme;
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
renderMessages();
if(typeof syncTopbar==='function') syncTopbar();
if(typeof renderSessionList==='function') renderSessionList();
showToast('Settings saved');
showToast(t('settings_saved'));
$('settingsOverlay').style.display='none';
}catch(e){
showToast('Save failed: '+e.message);
showToast(t('settings_save_failed')+e.message);
}
}

View File

@@ -45,7 +45,7 @@ async function loadSession(sid){
if(tc&&tc.name) appendLiveToolCard(tc);
}
syncTopbar();await loadDir('.');renderMessages();appendThinking();
setBusy(true);setStatus('Hermes is thinking\u2026');
setBusy(true);setStatus((window._botName||'Hermes')+' is thinking\u2026');
startApprovalPolling(sid);
}else{
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
@@ -429,7 +429,7 @@ async function deleteSession(sid){
if(remaining.sessions&&remaining.sessions.length){
await loadSession(remaining.sessions[0].session_id);
}else{
$('topbarTitle').textContent='Hermes';
$('topbarTitle').textContent=window._botName||'Hermes';
$('topbarMeta').textContent='Start a new conversation';
$('msgInner').innerHTML='';
$('emptyState').style.display='';

View File

@@ -100,6 +100,15 @@
--focus-ring:rgba(129,161,193,.35);--focus-glow:rgba(129,161,193,.08);
--strong:#eceff4;--em:#b8c0cc;--code-text:#a3be8c;--code-inline-bg:rgba(0,0,0,.2);--pre-text:#d8dee9;
}
/* ── OLED theme ── */
:root[data-theme="oled"]{
--bg:#000000;--sidebar:#000000;--border:rgba(255,255,255,0.06);--border2:rgba(255,255,255,0.12);
--text:#e0e0e0;--muted:#777777;--accent:#ff3b5c;--blue:#6cb4ff;--gold:#d4a74a;--code-bg:#080808;
--surface:#0a0a0a;--topbar-bg:rgba(0,0,0,.98);--main-bg:rgba(0,0,0,0.5);
--focus-ring:rgba(108,180,255,.3);--focus-glow:rgba(108,180,255,.06);
--strong:#ffffff;--em:#c0c0d0;--code-text:#e8b86d;--code-inline-bg:rgba(255,255,255,.06);--pre-text:#d0d0d8;
--input-bg:rgba(255,255,255,.03);--hover-bg:rgba(255,255,255,.05);
}
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
@@ -155,19 +164,27 @@
/* ── Approval card ── */
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
.approval-card.visible{display:block;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:16px 18px;}
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;}
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;}
.approval-btn:hover{background:rgba(255,255,255,0.12);}
.approval-btn.once{border-color:rgba(124,185,255,0.5);color:var(--blue);}
.approval-btn.once:hover{background:rgba(124,185,255,0.15);}
.approval-btn.session{border-color:rgba(124,185,255,0.3);color:var(--blue);}
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.approval-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 15px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;white-space:nowrap;}
.approval-btn:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2);}
.approval-btn:active{transform:translateY(0);box-shadow:none;}
.approval-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;}
.approval-btn-icon{font-size:13px;line-height:1;}
.approval-btn-label{line-height:1;}
.approval-kbd{display:inline-flex;align-items:center;justify-content:center;padding:1px 5px;border-radius:4px;font-size:10px;font-family:inherit;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:var(--muted);line-height:1.4;margin-left:2px;}
.approval-btn.once{border-color:rgba(124,185,255,0.6);color:var(--blue);background:rgba(124,185,255,0.08);}
.approval-btn.once:hover{background:rgba(124,185,255,0.18);border-color:rgba(124,185,255,0.8);}
.approval-btn.session{border-color:rgba(124,185,255,0.35);color:var(--blue);}
.approval-btn.session:hover{background:rgba(124,185,255,0.12);border-color:rgba(124,185,255,0.55);}
.approval-btn.always{border-color:rgba(201,168,76,0.5);color:var(--gold);}
.approval-btn.always:hover{background:rgba(201,168,76,0.12);border-color:rgba(201,168,76,0.7);}
.approval-btn.deny{border-color:rgba(233,69,96,0.5);color:var(--accent);}
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);}
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);border-color:rgba(233,69,96,0.7);}
.approval-btn.loading{opacity:.7;cursor:wait;}
/* Sidebar navigation tabs */
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
@@ -453,6 +470,7 @@
.approval-card{padding:0 10px 8px;}
.approval-btns{gap:6px;}
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
.approval-kbd{display:none;}
/* Tool cards */
.tool-card{margin-left:0!important;font-size:12px;}
/* Settings modal */

View File

@@ -237,7 +237,7 @@ function setStatus(t){
txt.textContent=t;
bar.style.display='';
// Show dismiss X only for static/error messages, not transient busy ones
const transient = t.endsWith('…') || t === 'Hermes is thinking';
const transient = t.endsWith('…') || t === (window._botName||'Hermes')+' is thinking\u2026';
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
}
}
@@ -387,12 +387,12 @@ async function checkInflightOnBoot(sid) {
const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`);
if (status.active) {
// Stream is genuinely still running -- show the banner
showReconnectBanner('A response is still being generated. Reload when ready?');
showReconnectBanner(t('reconnect_active'));
} else {
// Stream finished. Only show banner if reload happened within 90 seconds
// (longer gap = normal completed session, not a mid-stream reload)
if (Date.now() - ts < 90 * 1000) {
showReconnectBanner('A response was in progress when you last left. Messages may have updated.');
showReconnectBanner(t('reconnect_finished'));
} else {
clearInflight(); // completed normally, no banner needed
}
@@ -402,19 +402,19 @@ async function checkInflightOnBoot(sid) {
function syncTopbar(){
if(!S.session){
document.title='Hermes';
document.title=window._botName||'Hermes';
// Show default workspace name even without a session
const sidebarName=$('sidebarWsName');
if(sidebarName && sidebarName.textContent==='Workspace'){
sidebarName.textContent='No workspace';
sidebarName.textContent=t('no_workspace');
}
return;
}
const sessionTitle=S.session.title||'Untitled';
const sessionTitle=S.session.title||t('untitled');
$('topbarTitle').textContent=sessionTitle;
document.title=sessionTitle+' \u2014 Hermes';
document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes');
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
$('topbarMeta').textContent=`${vis.length} messages`;
$('topbarMeta').textContent=t('n_messages',vis.length);
// If a profile switch just happened, apply its model rather than the session's stale value.
// S._pendingProfileModel is set by switchToProfile() and cleared here after one application.
const modelOverride=S._pendingProfileModel;
@@ -424,11 +424,16 @@ function syncTopbar(){
} else {
const m=S.session.model||'';
const applied=_applyModelToDropdown(m,$('modelSelect'));
// If the model isn't in the list at all, add it so the session value is preserved
// If the model isn't in the current provider list, add it as a visually marked
// "(unavailable)" entry so the session value is preserved without misleading the user.
// Selecting it will still attempt to send (same as before), but the label makes
// clear it's a stale model from a previous session.
if(!applied && m){
const opt=document.createElement('option');
opt.value=m;
opt.textContent=getModelLabel(m);
opt.textContent=getModelLabel(m)+t('model_unavailable');
opt.style.color='var(--muted, #888)';
opt.title=t('model_unavailable_title');
$('modelSelect').appendChild(opt);
$('modelSelect').value=m;
}
@@ -465,16 +470,24 @@ function renderMessages(){
const inner=$('msgInner');
const vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
// Keep assistant messages with tool_use content even if they have no text,
// so tool cards can be anchored to their DOM rows on page reload (#140).
if(m.role==='assistant'&&Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'))return true;
return msgContent(m)||m.attachments?.length;
});
$('emptyState').style.display=vis.length?'none':'';
inner.innerHTML='';
// Track original indices (in S.messages) so truncate knows the cut point
// Track original indices (in S.messages) so truncate knows the cut point.
// Also include assistant messages that have tool_calls (OpenAI format) or
// tool_use content (Anthropic format) even when their text is empty — these
// rows serve as DOM anchors for tool card insertion on page reload.
const visWithIdx=[];
let rawIdx=0;
for(const m of S.messages){
if(!m||!m.role||m.role==='tool'){rawIdx++;continue;}
if(msgContent(m)||m.attachments?.length) visWithIdx.push({m,rawIdx});
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu))) visWithIdx.push({m,rawIdx});
rawIdx++;
}
for(let vi=0;vi<visWithIdx.length;vi++){
@@ -486,12 +499,32 @@ function renderMessages(){
thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
// Also check top-level reasoning field (Hermes format)
if(!thinkingText && m.reasoning){
thinkingText=m.reasoning;
}
// Parse inline thinking tags from plain text: <think>...</think> (DeepSeek, QwQ, etc.)
// and Gemma 4 channel tokens: <|channel>thought\n...<channel|>
if(!thinkingText && typeof content==='string'){
const thinkMatch=content.match(/^<think>([\s\S]*?)<\/think>\s*/);
if(thinkMatch){
thinkingText=thinkMatch[1].trim();
content=content.slice(thinkMatch[0].length);
}
if(!thinkingText){
const gemmaMatch=content.match(/^<\|channel>thought\n([\s\S]*?)<channel\|>\s*/);
if(gemmaMatch){
thinkingText=gemmaMatch[1].trim();
content=content.slice(gemmaMatch[0].length);
}
}
}
const isUser=m.role==='user';
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
// Render thinking card before the assistant message (collapsed by default)
if(thinkingText&&!isUser){
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">&#128161;</span><span class="thinking-card-label">Thinking</span><span class="thinking-card-toggle">&#9656;</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">&#128161;</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">&#9656;</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
inner.appendChild(thinkRow);
}
const row=document.createElement('div');row.className='msg-row';
@@ -501,17 +534,40 @@ function renderMessages(){
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">&#128206; ${esc(f)}</div>`).join('')}</div>`;
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
// Action buttons for this bubble
const editBtn = isUser ? `<button class="msg-action-btn" title="Edit message" onclick="editMessage(this)">&#9998;</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="Regenerate response" onclick="regenerateResponse(this)">&#8635;</button>` : '';
const editBtn = isUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">&#9998;</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">&#8635;</button>` : '';
const tsVal=m._ts||m.timestamp;
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':'H'}</div><span style="font-size:12px">${isUser?'You':'Hermes'}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">&#128203;</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
const _bn=window._botName||'Hermes';
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?t('you'):esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">&#128203;</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
row.dataset.rawText = String(content).trim();
inner.appendChild(row);
}
// Insert settled tool call cards (history view only).
// During live streaming, tool cards are rendered in #liveToolCards by the
// tool SSE handler and never mixed into the message list until done fires.
//
// Fallback: if S.toolCalls is empty (sessions that predate session-level tool
// tracking, or runs that didn't go through the normal streaming path), build
// a display list from per-message tool_calls (OpenAI format) stored in each
// assistant message. This covers the reload case described in issue #140.
if(!S.busy && (!S.toolCalls||!S.toolCalls.length)){
const derived=[];
S.messages.forEach((m,rawIdx)=>{
if(m.role!=='assistant') return;
(m.tool_calls||[]).forEach(tc=>{
if(!tc||typeof tc!=='object') return;
const fn=tc.function||{};
const name=fn.name||tc.name||'tool';
let args={};
try{ args=JSON.parse(fn.arguments||'{}'); }catch(e){}
let argsSnap={};
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
derived.push({name,snippet:'',tid:tc.id||tc.call_id||'',assistant_msg_idx:rawIdx,args:argsSnap,done:true});
});
});
if(derived.length) S.toolCalls=derived;
}
if(!S.busy && S.toolCalls && S.toolCalls.length){
inner.querySelectorAll('.tool-card-row').forEach(el=>el.remove());
const byAssistant = {};
@@ -521,18 +577,35 @@ function renderMessages(){
byAssistant[key].push(tc);
}
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
// Track the last inserted node per anchor so back-to-back groups for the
// same (filtered) anchor row are inserted in chronological order.
const anchorInsertAfter = new Map();
for(const [key, cards] of Object.entries(byAssistant)){
const aIdx = parseInt(key);
let insertBefore = null;
if(aIdx === -1){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=allRows[i];break;}
}
} else {
// Find the right insertion point: cards go AFTER the assistant message
// that triggered them. We look for the row at aIdx, or the nearest
// visible ASSISTANT row at or before aIdx (the assistant message may be
// filtered out if it contained only tool_use blocks with no text response).
let anchorRow = null;
if(aIdx >= 0){
// First: exact match for the assistant row
for(const r of allRows){
const ri=parseInt(r.dataset.msgIdx||'-1');
if(ri>aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=r;break;}
if(ri===aIdx){anchorRow=r;break;}
}
// Fallback: nearest visible ASSISTANT row at or before aIdx
if(!anchorRow){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1');
if(ri<=aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
}
}
}
// aIdx === -1 or no assistant anchor found: attach after the last assistant row
if(!anchorRow){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
}
}
const frag=document.createDocumentFragment();
@@ -544,17 +617,24 @@ function renderMessages(){
// Collect card elements before they get moved to DOM
const cardEls=Array.from(frag.querySelectorAll('.tool-card'));
const expandBtn=document.createElement('button');
expandBtn.textContent='Expand all';
expandBtn.textContent=t('expand_all');
expandBtn.onclick=()=>cardEls.forEach(c=>c.classList.add('open'));
const collapseBtn=document.createElement('button');
collapseBtn.textContent='Collapse all';
collapseBtn.textContent=t('collapse_all');
collapseBtn.onclick=()=>cardEls.forEach(c=>c.classList.remove('open'));
toggle.appendChild(expandBtn);
toggle.appendChild(collapseBtn);
frag.insertBefore(toggle,frag.firstChild);
}
if(insertBefore) inner.insertBefore(frag,insertBefore);
// Insert after the anchor row (or after any previously inserted group for
// the same anchor), preserving chronological order for multi-step chains.
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
if(refNode) inner.insertBefore(frag,refNode);
else inner.appendChild(frag);
// Record the last child we inserted so the next group for this anchor
// goes after it rather than back at anchorRow.nextSibling.
anchorInsertAfter.set(anchorRow, inner.lastChild);
}
}
// Render usage badge on the last assistant message row (if enabled and usage data exists)
@@ -727,7 +807,7 @@ async function submitEdit(msgIdx, newText) {
// Now send the edited message as a new chat
$('msg').value = newText;
await send();
} catch(e) { setStatus('Edit failed: ' + e.message); }
} catch(e) { setStatus(t('edit_failed') + e.message); }
}
async function regenerateResponse(btn) {
@@ -753,7 +833,7 @@ async function regenerateResponse(btn) {
renderMessages();
$('msg').value = lastUserText;
await send();
} catch(e) { setStatus('Regenerate failed: ' + e.message); }
} catch(e) { setStatus(t('regen_failed') + e.message); }
}
function highlightCode(container) {
@@ -772,12 +852,12 @@ function addCopyButtons(container){
if(pre.querySelector('.code-copy-btn')) return;
const btn=document.createElement('button');
btn.className='code-copy-btn';
btn.textContent='Copy';
btn.textContent=t('copy');
btn.onclick=(e)=>{
e.stopPropagation();
navigator.clipboard.writeText(codeEl.textContent).then(()=>{
btn.textContent='Copied!';
setTimeout(()=>{btn.textContent='Copy';},1500);
btn.textContent=t('copied');
setTimeout(()=>{btn.textContent=t('copy');},1500);
});
};
const header=pre.previousElementSibling;
@@ -930,7 +1010,7 @@ function _renderTreeItems(container, entries, depth){
// Name
const nameEl=document.createElement('span');
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title='Double-click to rename';
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');
nameEl.ondblclick=(e)=>{
e.stopPropagation();
// For directories, double-click navigates (breadcrumb view)
@@ -947,11 +1027,11 @@ function _renderTreeItems(container, entries, depth){
await api('/api/file/rename',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,path:item.path,new_name:newName
})});
showToast(`Renamed to ${newName}`);
showToast(t('renamed_to')+newName);
// Invalidate cache and re-render
delete S._dirCache[S.currentDir];
await loadDir(S.currentDir);
}catch(err){showToast('Rename failed: '+err.message);}
}catch(err){showToast(t('rename_failed')+err.message);}
}
}
inp.replaceWith(nameEl);
@@ -977,7 +1057,7 @@ function _renderTreeItems(container, entries, depth){
// Delete button -- for files
if(item.type==='file'){
const del=document.createElement('button');
del.className='file-del-btn';del.title='Delete';del.textContent='\u00d7';
del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7';
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
el.appendChild(del);
}
@@ -1018,7 +1098,7 @@ function _renderTreeItems(container, entries, depth){
const empty=document.createElement('div');
empty.className='file-item file-empty';
empty.style.paddingLeft=(8+(depth+1)*16)+'px';
empty.textContent='(empty)';
empty.textContent=t('empty_dir');
container.appendChild(empty);
}
}
@@ -1027,39 +1107,39 @@ function _renderTreeItems(container, entries, depth){
async function deleteWorkspaceFile(relPath, name){
if(!S.session)return;
if(!confirm(`Delete ${name}?`))return;
if(!confirm(t('delete_confirm',name)))return;
try{
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
showToast(`Deleted ${name}`);
showToast(t('deleted')+name);
// Close preview if we just deleted the viewed file
if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick();
await loadDir(S.currentDir);
}catch(e){setStatus('Delete failed: '+e.message);}
}catch(e){setStatus(t('delete_failed')+e.message);}
}
async function promptNewFile(){
if(!S.session)return;
const name=prompt('New file name (e.g. notes.md):','');
const name=prompt(t('new_file_prompt'),'');
if(!name||!name.trim())return;
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
try{
await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,content:''})});
showToast(`Created ${name.trim()}`);
showToast(t('created')+name.trim());
await loadDir(S.currentDir);
openFile(relPath);
}catch(e){setStatus('Create failed: '+e.message);}
}catch(e){setStatus(t('create_failed')+e.message);}
}
async function promptNewFolder(){
if(!S.session)return;
const name=prompt('New folder name:','');
const name=prompt(t('new_folder_prompt'),'');
if(!name||!name.trim())return;
const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim());
try{
await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
showToast(`Created folder ${name.trim()}`);
showToast(t('folder_created')+name.trim());
await loadDir(S.currentDir);
}catch(e){setStatus('Create folder failed: '+e.message);}
}catch(e){setStatus(t('folder_create_failed')+e.message);}
}
function renderTray(){
@@ -1069,7 +1149,7 @@ function renderTray(){
updateSendBtn();
S.pendingFiles.forEach((f,i)=>{
const chip=document.createElement('div');chip.className='attach-chip';
chip.innerHTML=`&#128206; ${esc(f.name)} <button title="Remove">&#10005;</button>`;
chip.innerHTML=`&#128206; ${esc(f.name)} <button title="${t('remove_title')}">&#10005;</button>`;
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
tray.appendChild(chip);
});
@@ -1091,12 +1171,12 @@ async function uploadPendingFiles(){
const data=await res.json();
if(data.error)throw new Error(data.error);
names.push(data.filename);
}catch(e){failures++;setStatus(`\u274c Upload failed: ${f.name} \u2014 ${e.message}`);}
}catch(e){failures++;setStatus(`\u274c ${t('upload_failed')}${f.name} \u2014 ${e.message}`);}
bar.style.width=`${Math.round((i+1)/total*100)}%`;
}
barWrap.classList.remove('active');bar.style.width='0%';
S.pendingFiles=[];renderTray();
if(failures===total&&total>0)throw new Error(`All ${total} upload(s) failed`);
if(failures===total&&total>0)throw new Error(t('all_uploads_failed',total));
return names;
}

View File

@@ -54,7 +54,7 @@ async function loadDir(path){
}
if(typeof clearPreview==='function'){
if(typeof _previewDirty!=='undefined'&&_previewDirty){
if(confirm('You have unsaved changes in the preview. Discard and navigate?'))clearPreview();
if(confirm(t('unsaved_confirm')))clearPreview();
}else{
clearPreview();
}
@@ -131,8 +131,8 @@ function updateEditBtn(){
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? '&#128190; Save' : '&#9998; Edit';
btn.title = editing ? 'Save changes' : 'Edit this file';
btn.innerHTML = editing ? `&#128190; ${t('save')}` : `&#9998; ${t('edit')}`;
btn.title = editing ? t('save_title') : t('edit_title');
btn.style.color = editing ? 'var(--blue)' : '';
if(_previewDirty) btn.innerHTML = '&#128190; Save*';
}
@@ -154,8 +154,8 @@ async function toggleEditMode(){
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
else $('previewMd').style.display='';
showToast('Saved');
}catch(e){setStatus('Save failed: '+e.message);}
showToast(t('saved'));
}catch(e){setStatus(t('save_failed')+e.message);}
}else{
// Enter edit mode: populate textarea with current content
const currentText = _previewCurrentMode==='code'
@@ -206,7 +206,7 @@ async function openFile(path){
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
$('previewImg').alt=path;
$('previewImg').src=url;
$('previewImg').onerror=()=>setStatus('Could not load image');
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
} else if(MD_EXTS.has(ext)){
// Markdown: fetch text, render with renderMd, display as formatted HTML
try{
@@ -214,7 +214,7 @@ async function openFile(path){
showPreview('md');
_previewRawContent = data.content;
$('previewMd').innerHTML=renderMd(data.content);
}catch(e){setStatus('Could not open file');}
}catch(e){setStatus(t('file_open_failed'));}
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
@@ -242,6 +242,6 @@ function downloadFile(path){
a.href=url;a.download=filename;
document.body.appendChild(a);a.click();
setTimeout(()=>document.body.removeChild(a),100);
showToast(`Downloading ${filename}\u2026`,2000);
showToast(t('downloading',filename),2000);
}

View File

@@ -0,0 +1,286 @@
"""
Tests for fix/approval-stuck-thinking:
Verify that /api/approval/respond correctly unblocks gateway approval queues
and that the approval module exports the symbols streaming.py and routes.py
need to prevent the UI getting stuck in "Thinking…" during dangerous commands.
"""
import json
import threading
import uuid
import urllib.request
import urllib.error
import urllib.parse
import pytest
# Import approval internals — shared module-level state within this process.
# The HTTP tests use the test server (port 8788, separate process).
# The unit tests operate directly on the module.
try:
from tools.approval import (
register_gateway_notify,
unregister_gateway_notify,
resolve_gateway_approval,
_gateway_queues,
_gateway_notify_cbs,
_lock,
_ApprovalEntry,
submit_pending,
has_pending,
pop_pending,
)
APPROVAL_AVAILABLE = True
except ImportError:
APPROVAL_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not APPROVAL_AVAILABLE,
reason="tools.approval not available in this environment"
)
BASE = "http://127.0.0.1:8788"
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── Unit tests (in-process, no HTTP server needed) ──────────────────────────
class TestGatewayApprovalUnblocking:
"""Unit tests for the gateway queue unblocking mechanism."""
def test_resolve_gateway_approval_sets_event(self):
"""resolve_gateway_approval() must set the entry's event and store the result."""
sid = f"unit-resolve-{uuid.uuid4().hex[:8]}"
data = {"command": "rm -rf /tmp/x", "description": "recursive delete"}
entry = _ApprovalEntry(data)
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolved = resolve_gateway_approval(sid, "once", resolve_all=False)
assert resolved == 1
assert entry.event.is_set()
assert entry.result == "once"
# Queue should be cleaned up
with _lock:
assert sid not in _gateway_queues
def test_resolve_gateway_approval_deny(self):
"""Deny choice is propagated correctly."""
sid = f"unit-deny-{uuid.uuid4().hex[:8]}"
entry = _ApprovalEntry({"command": "pkill -9 x", "description": "force kill"})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
resolve_gateway_approval(sid, "deny")
assert entry.result == "deny"
def test_resolve_gateway_approval_no_queue_is_harmless(self):
"""resolve_gateway_approval with no queue entry returns 0, no crash."""
sid = f"unit-no-queue-{uuid.uuid4().hex[:8]}"
result = resolve_gateway_approval(sid, "once")
assert result == 0
def test_resolve_all_unblocks_multiple_entries(self):
"""resolve_all=True unblocks every pending entry in the queue."""
sid = f"unit-resolve-all-{uuid.uuid4().hex[:8]}"
entries = [_ApprovalEntry({"command": f"cmd{i}"}) for i in range(3)]
with _lock:
_gateway_queues[sid] = list(entries)
resolved = resolve_gateway_approval(sid, "session", resolve_all=True)
assert resolved == 3
for e in entries:
assert e.event.is_set()
assert e.result == "session"
def test_register_and_fire_notify_cb(self):
"""register_gateway_notify stores the cb; calling it delivers approval data."""
sid = f"unit-notify-{uuid.uuid4().hex[:8]}"
fired = []
register_gateway_notify(sid, lambda d: fired.append(d))
with _lock:
cb = _gateway_notify_cbs.get(sid)
assert cb is not None
data = {"command": "test", "description": "test"}
cb(data)
assert fired == [data]
unregister_gateway_notify(sid)
def test_unregister_clears_cb_and_signals_entries(self):
"""unregister_gateway_notify removes cb and unblocks any queued entries."""
sid = f"unit-unreg-{uuid.uuid4().hex[:8]}"
register_gateway_notify(sid, lambda d: None)
entry = _ApprovalEntry({"command": "x"})
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
unregister_gateway_notify(sid)
assert entry.event.is_set(), "unregister should signal blocked entries"
with _lock:
assert sid not in _gateway_notify_cbs
assert sid not in _gateway_queues
def test_streaming_approval_integration(self):
"""
End-to-end unit simulation of the streaming.py fix:
1. streaming.py registers notify_cb
2. check_all_command_guards fires notify_cb (pushing approval SSE)
3. User responds — resolve_gateway_approval unblocks agent thread
4. Agent thread sees choice and continues
"""
sid = f"unit-e2e-{uuid.uuid4().hex[:8]}"
approval_events_sent = []
# Step 1: streaming.py registers the notify callback
def _approval_notify_cb(approval_data):
approval_events_sent.append(approval_data) # would be put('approval', ...)
register_gateway_notify(sid, _approval_notify_cb)
# Step 2: check_all_command_guards fires the callback and queues an entry
approval_data = {
"command": "rm -rf /tmp/test",
"pattern_key": "recursive delete",
"pattern_keys": ["recursive delete"],
"description": "recursive delete",
}
entry = _ApprovalEntry(approval_data)
with _lock:
_gateway_queues.setdefault(sid, []).append(entry)
# notify_cb fires synchronously (gateway notifies user)
with _lock:
cb = _gateway_notify_cbs.get(sid)
cb(approval_data)
assert len(approval_events_sent) == 1, "approval SSE event should have been queued"
# Step 3: user responds via /api/approval/respond → resolve_gateway_approval
resolved = resolve_gateway_approval(sid, "once")
assert resolved == 1
# Step 4: agent thread is unblocked with the correct choice
assert entry.event.is_set()
assert entry.result == "once"
# Cleanup
unregister_gateway_notify(sid)
# ── Symbol existence tests ───────────────────────────────────────────────────
class TestApprovalModuleExports:
"""Verify the module exports all symbols that streaming.py and routes.py need."""
def test_register_gateway_notify_exported(self):
import tools.approval as ap
assert hasattr(ap, "register_gateway_notify"), \
"tools.approval must export register_gateway_notify"
def test_unregister_gateway_notify_exported(self):
import tools.approval as ap
assert hasattr(ap, "unregister_gateway_notify"), \
"tools.approval must export unregister_gateway_notify"
def test_resolve_gateway_approval_exported(self):
import tools.approval as ap
assert hasattr(ap, "resolve_gateway_approval"), \
"tools.approval must export resolve_gateway_approval"
def test_approval_entry_exported(self):
import tools.approval as ap
assert hasattr(ap, "_ApprovalEntry"), \
"tools.approval must export _ApprovalEntry"
# ── HTTP regression tests (test server, port 8788) ───────────────────────────
class TestApprovalHTTPEndpoints:
"""
Regression tests for /api/approval/respond against the live test server.
These verify that the HTTP layer behaves correctly — they don't rely on
in-process module state shared with the server subprocess.
"""
def test_respond_returns_ok_no_pending(self):
"""respond with no pending entry returns ok (no crash, no 500)."""
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
result, status = post("/api/approval/respond", {
"session_id": sid,
"choice": "deny",
})
assert status == 200
assert result["ok"] is True
def test_respond_clears_injected_pending(self):
"""Inject a pending entry, respond, verify it's cleared."""
sid = f"http-clear-{uuid.uuid4().hex[:8]}"
cmd = "rm -rf /tmp/testdir"
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(sid)}"
f"&pattern_key=recursive+delete&command={urllib.parse.quote(cmd)}")
assert inject["ok"] is True
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is not None
result, status = post("/api/approval/respond", {
"session_id": sid,
"choice": "deny",
})
assert status == 200
assert result["ok"] is True
data2 = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
assert data2["pending"] is None, "pending should be cleared after respond"
def test_respond_rejects_invalid_choice(self):
"""respond with an unknown choice returns 400."""
result, status = post("/api/approval/respond", {
"session_id": "some-session",
"choice": "INVALID",
})
assert status == 400
def test_respond_requires_session_id(self):
"""respond without session_id returns 400."""
result, status = post("/api/approval/respond", {"choice": "deny"})
assert status == 400
def test_respond_session_choice_clears_pending(self):
"""Inject pending, respond with 'session', verify cleared."""
sid = f"http-session-{uuid.uuid4().hex[:8]}"
inject = get(f"/api/approval/inject_test?session_id={urllib.parse.quote(sid)}"
f"&pattern_key=force+kill+processes&command=pkill+-9+something")
assert inject["ok"] is True
result, status = post("/api/approval/respond", {
"session_id": sid,
"choice": "session",
})
assert status == 200
assert result["choice"] == "session"
data = get(f"/api/approval/pending?session_id={urllib.parse.quote(sid)}")
assert data["pending"] is None

View File

@@ -0,0 +1,204 @@
"""
Tests for resolve_model_provider() model routing logic.
Verifies that model IDs are correctly resolved to (model, provider, base_url)
tuples for different provider configurations.
"""
import api.config as config
def _resolve_with_config(model_id, provider=None, base_url=None, default=None):
"""Helper: temporarily set config.cfg model section, call resolve, restore."""
old_cfg = dict(config.cfg)
model_cfg = {}
if provider:
model_cfg['provider'] = provider
if base_url:
model_cfg['base_url'] = base_url
if default:
model_cfg['default'] = default
config.cfg['model'] = model_cfg if model_cfg else {}
try:
return config.resolve_model_provider(model_id)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
# ── OpenRouter prefix handling ────────────────────────────────────────────
def test_openrouter_free_keeps_full_path():
"""openrouter/free must NOT be stripped to 'free' when provider is openrouter."""
model, provider, base_url = _resolve_with_config(
'openrouter/free', provider='openrouter',
base_url='https://openrouter.ai/api/v1',
)
assert model == 'openrouter/free', f"Expected 'openrouter/free', got '{model}'"
assert provider == 'openrouter'
def test_openrouter_model_with_provider_prefix():
"""anthropic/claude-sonnet-4.6 via openrouter keeps full path."""
model, provider, base_url = _resolve_with_config(
'anthropic/claude-sonnet-4.6', provider='openrouter',
base_url='https://openrouter.ai/api/v1',
)
assert model == 'anthropic/claude-sonnet-4.6'
assert provider == 'openrouter'
# ── Direct provider prefix stripping ─────────────────────────────────────
def test_anthropic_prefix_stripped_for_direct_api():
"""anthropic/claude-sonnet-4.6 strips prefix when provider is anthropic."""
model, provider, base_url = _resolve_with_config(
'anthropic/claude-sonnet-4.6', provider='anthropic',
)
assert model == 'claude-sonnet-4.6'
assert provider == 'anthropic'
def test_openai_prefix_stripped_for_direct_api():
"""openai/gpt-5.4-mini strips prefix when provider is openai."""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4-mini', provider='openai',
)
assert model == 'gpt-5.4-mini'
assert provider == 'openai'
# ── Cross-provider routing ───────────────────────────────────────────────
def test_cross_provider_routes_through_openrouter():
"""Picking openai model when config is anthropic routes via openrouter."""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4-mini', provider='anthropic',
)
assert model == 'openai/gpt-5.4-mini'
assert provider == 'openrouter'
assert base_url is None # openrouter uses its own endpoint
# ── Bare model names ─────────────────────────────────────────────────────
def test_bare_model_uses_config_provider():
"""A model name without / uses the config provider and base_url."""
model, provider, base_url = _resolve_with_config(
'gemma-4-26B', provider='custom',
base_url='http://192.168.1.160:4000',
)
assert model == 'gemma-4-26B'
assert provider == 'custom'
assert base_url == 'http://192.168.1.160:4000'
def test_empty_model_returns_config_defaults():
"""Empty model string returns config provider and base_url."""
model, provider, base_url = _resolve_with_config(
'', provider='anthropic',
)
assert model == ''
assert provider == 'anthropic'
# ── @provider:model hint routing (Issue #138 v2) ────────────────────────
def test_provider_hint_routes_to_specific_provider():
"""@minimax:MiniMax-M2.7 routes to minimax provider directly."""
model, provider, base_url = _resolve_with_config(
'@minimax:MiniMax-M2.7', provider='anthropic',
)
assert model == 'MiniMax-M2.7'
assert provider == 'minimax'
assert base_url is None # resolve_runtime_provider will fill this
def test_provider_hint_zai():
"""@zai:GLM-5 routes to zai provider directly."""
model, provider, base_url = _resolve_with_config(
'@zai:GLM-5', provider='openai',
)
assert model == 'GLM-5'
assert provider == 'zai'
def test_provider_hint_deepseek():
"""@deepseek:deepseek-chat routes to deepseek provider."""
model, provider, base_url = _resolve_with_config(
'@deepseek:deepseek-chat', provider='anthropic',
)
assert model == 'deepseek-chat'
assert provider == 'deepseek'
def test_slash_prefix_non_default_still_routes_openrouter():
"""minimax/MiniMax-M2.7 (old format) still routes through openrouter."""
model, provider, base_url = _resolve_with_config(
'minimax/MiniMax-M2.7', provider='anthropic',
)
assert model == 'minimax/MiniMax-M2.7'
assert provider == 'openrouter'
# ── get_available_models() @provider: hint behaviour ──────────────────────
def _available_models_with_provider(provider):
"""Helper: temporarily set active_provider in config."""
old_cfg = dict(config.cfg)
config.cfg['model'] = {'provider': provider}
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
def test_non_default_provider_models_use_hint_prefix():
"""With anthropic as default, minimax model IDs should use @minimax: prefix."""
result = _available_models_with_provider('anthropic')
groups = {g['provider']: g['models'] for g in result['groups']}
if 'MiniMax' in groups:
for m in groups['MiniMax']:
assert m['id'].startswith('@minimax:'), (
f"Expected @minimax: prefix, got: {m['id']!r}"
)
def test_no_duplicate_when_default_model_is_prefixed():
"""Issue #147 Bug 2: 'anthropic/claude-opus-4.6' as default_model must not
inject a duplicate alongside the existing bare 'claude-opus-4.6' entry in
the same provider group."""
import api.config as _cfg
old_cfg = dict(_cfg.cfg)
_cfg.cfg['model'] = {
'provider': 'anthropic',
'default': 'anthropic/claude-opus-4.6',
}
try:
result = _cfg.get_available_models()
norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid
# Check each group individually: no group should have two entries that
# normalize to the same bare model name
for g in result['groups']:
bare_ids = [norm(m['id']) for m in g['models']]
duplicates = [mid for mid in set(bare_ids) if bare_ids.count(mid) > 1]
assert not duplicates, (
f"Provider group '{g['provider']}' has duplicate models after normalization: "
f"{duplicates}\nFull group: {[m['id'] for m in g['models']]}"
)
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
def test_default_provider_models_not_prefixed():
"""The active provider's models remain bare (no @prefix added)."""
import api.config as _cfg
raw_anthropic_ids = {m['id'] for m in _cfg._PROVIDER_MODELS.get('anthropic', [])}
result = _available_models_with_provider('anthropic')
groups = {g['provider']: g['models'] for g in result['groups']}
if 'Anthropic' in groups:
returned_ids = {m['id'] for m in groups['Anthropic']}
for bare_id in raw_anthropic_ids:
assert bare_id in returned_ids, (
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group"
)

View File

@@ -213,9 +213,14 @@ def test_boot_js_recognition_interim_results():
def test_boot_js_recognition_lang_en():
"""recognition.lang must be set to en-US."""
"""recognition.lang must be set (static en-US or dynamic via _locale._speech)."""
js, _ = get_text("/static/boot.js")
assert "recognition.lang='en-US'" in js or 'recognition.lang = "en-US"' in js or "recognition.lang='en-US'" in js
# Accept either the old static value or the new locale-driven assignment
assert (
"recognition.lang='en-US'" in js
or 'recognition.lang = "en-US"' in js
or "recognition.lang=" in js # dynamic: recognition.lang=(_locale._speech)||'en-US'
)
def test_boot_js_onresult_handler():

136
tests/test_sprint27.py Normal file
View File

@@ -0,0 +1,136 @@
"""
Sprint 27 Tests: configurable assistant display name (bot_name).
Tests cover settings API round-trip, empty/missing input defaults,
login page rendering, and server-side sanitization.
"""
import json
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def get_raw(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return r.read().decode(), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── Default value ─────────────────────────────────────────────────────────
def test_settings_default_bot_name():
"""GET /api/settings should return bot_name defaulting to 'Hermes'."""
d, status = get("/api/settings")
assert status == 200
assert "bot_name" in d
assert d["bot_name"] == "Hermes"
# ── Round-trip ────────────────────────────────────────────────────────────
def test_settings_set_bot_name():
"""POST /api/settings with bot_name should persist and round-trip."""
try:
d, status = post("/api/settings", {"bot_name": "TestBot"})
assert status == 200
assert d.get("bot_name") == "TestBot"
d2, _ = get("/api/settings")
assert d2.get("bot_name") == "TestBot"
finally:
post("/api/settings", {"bot_name": "Hermes"})
def test_settings_bot_name_special_chars():
"""bot_name with safe special characters should persist correctly."""
try:
d, status = post("/api/settings", {"bot_name": "My Assistant 2.0"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("bot_name") == "My Assistant 2.0"
finally:
post("/api/settings", {"bot_name": "Hermes"})
# ── Server-side sanitization ──────────────────────────────────────────────
def test_settings_empty_bot_name_defaults_to_hermes():
"""Posting an empty bot_name should default to 'Hermes' server-side."""
try:
d, status = post("/api/settings", {"bot_name": ""})
assert status == 200
assert d.get("bot_name") == "Hermes"
d2, _ = get("/api/settings")
assert d2.get("bot_name") == "Hermes"
finally:
post("/api/settings", {"bot_name": "Hermes"})
def test_settings_whitespace_bot_name_defaults_to_hermes():
"""Posting a whitespace-only bot_name should default to 'Hermes'."""
try:
d, status = post("/api/settings", {"bot_name": " "})
assert status == 200
assert d.get("bot_name") == "Hermes"
finally:
post("/api/settings", {"bot_name": "Hermes"})
# ── Login page rendering ──────────────────────────────────────────────────
def test_login_page_shows_default_bot_name():
"""GET /login should contain 'Hermes' in title and h1 when default."""
html, status = get_raw("/login")
assert status == 200
assert "<title>Hermes" in html
assert "<h1>Hermes</h1>" in html
def test_login_page_shows_custom_bot_name():
"""GET /login should reflect the configured bot_name."""
try:
post("/api/settings", {"bot_name": "Aria"})
html, status = get_raw("/login")
assert status == 200
assert "<title>Aria" in html
assert "<h1>Aria</h1>" in html
finally:
post("/api/settings", {"bot_name": "Hermes"})
def test_login_page_empty_name_does_not_crash():
"""Login page must not 500 even if somehow bot_name is empty in settings."""
# Force an empty value by patching settings file directly — skipped here
# because the server-side guard in POST /api/settings prevents storing empty.
# Instead, verify that /login returns 200 reliably.
html, status = get_raw("/login")
assert status == 200
assert "Sign in" in html
def test_login_page_xss_escaped():
"""bot_name with HTML special chars should be escaped in the login page."""
try:
post("/api/settings", {"bot_name": "<script>alert(1)</script>"})
html, status = get_raw("/login")
assert status == 200
# Raw tag must not appear unescaped
assert "<script>alert(1)</script>" not in html
# Escaped form should appear
assert "&lt;script&gt;" in html
finally:
post("/api/settings", {"bot_name": "Hermes"})

224
tests/test_sprint28.py Normal file
View File

@@ -0,0 +1,224 @@
"""
Sprint 28 Tests: /personality slash command — backend API coverage.
Tests: GET /api/personalities, POST /api/personality/set, Session.compact(),
path traversal defence, size cap, clear personality.
"""
import json
import pathlib
import shutil
import sys
import urllib.error
import urllib.request
# Import test constants from conftest (same process — these are module-level values)
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def _personalities_dir():
"""Return the personalities directory the test server will look in.
conftest sets HERMES_HOME=TEST_STATE_DIR in the server's environment.
The server's api/profiles._DEFAULT_HERMES_HOME resolves to TEST_STATE_DIR,
so get_active_hermes_home() returns TEST_STATE_DIR, and personalities
live at TEST_STATE_DIR/personalities.
"""
p = TEST_STATE_DIR / 'personalities'
p.mkdir(parents=True, exist_ok=True)
return p
def _make_personality(name, content="# Test Bot\nA test personality."):
"""Create a personality directory with a SOUL.md."""
d = _personalities_dir() / name
d.mkdir(parents=True, exist_ok=True)
(d / "SOUL.md").write_text(content)
return d
def _make_session():
"""Create a new session and return its session_id."""
d, status = post("/api/session/new", {})
assert status == 200, f"Failed to create session: {d}"
return d["session"]["session_id"]
def _cleanup_session(sid):
try:
post("/api/session/delete", {"session_id": sid})
except Exception:
pass
# ── GET /api/personalities ────────────────────────────────────────────────────
def test_personalities_empty_when_none_exist():
"""GET /api/personalities returns empty list when no personalities exist."""
p_dir = _personalities_dir()
for child in list(p_dir.iterdir()):
if child.is_dir() and not child.is_symlink():
shutil.rmtree(child)
d, status = get("/api/personalities")
assert status == 200
assert d.get("personalities") == []
def test_personalities_lists_from_config():
"""GET /api/personalities returns personalities from config.yaml agent.personalities.
Skipped if no personalities configured in test environment.
"""
d, status = get("/api/personalities")
assert status == 200
assert isinstance(d.get("personalities"), list)
# If personalities are configured, verify structure
for p in d.get("personalities", []):
assert "name" in p
assert "description" in p
def test_personalities_returns_empty_when_none_configured():
"""GET /api/personalities returns empty list when no personalities in config."""
# The test server starts with a clean state dir (no config.yaml),
# so agent.personalities is empty by default
d, status = get("/api/personalities")
assert status == 200
# May or may not have personalities depending on the real ~/.hermes/config.yaml
# being loaded. Just verify the structure is correct.
assert isinstance(d.get("personalities"), list)
def test_personalities_skips_non_dict_config():
"""GET /api/personalities handles non-dict agent config gracefully."""
d, status = get("/api/personalities")
assert status == 200
assert isinstance(d.get("personalities"), list)
# ── POST /api/personality/set ─────────────────────────────────────────────────
_test_personalities = {}
def _inject_personality(name, value):
"""Write a personality into the test config.yaml so the server picks it up."""
_test_personalities[name] = value
_write_test_config()
def _remove_personality(name):
"""Remove a personality from the test config.yaml."""
_test_personalities.pop(name, None)
_write_test_config()
def _write_test_config():
"""Write config.yaml with test personalities using simple YAML format."""
TEST_STATE_DIR.mkdir(parents=True, exist_ok=True)
config_path = TEST_STATE_DIR / 'config.yaml'
lines = ['agent:', ' personalities:']
for pname, pval in _test_personalities.items():
if isinstance(pval, dict):
lines.append(f' {pname}:')
for k, v in pval.items():
lines.append(f' {k}: "{v}"')
else:
lines.append(f' {pname}: "{pval}"')
config_path.write_text('\n'.join(lines) + '\n')
def test_set_personality_valid():
"""Setting a personality that exists in config stores name and returns prompt.
Skipped if config.yaml has no personalities (common in test environments).
"""
# First check if any personalities are configured
d, status = get("/api/personalities")
if not d.get("personalities"):
return # skip — no personalities in test server config
name = d["personalities"][0]["name"]
sid = _make_session()
try:
d2, status2 = post("/api/personality/set", {"session_id": sid, "name": name})
assert status2 == 200
assert d2.get("ok") is True
assert d2.get("personality") == name
finally:
_cleanup_session(sid)
def test_set_personality_persists_in_compact():
"""After setting personality, GET /api/session returns personality in compact.
Skipped if config.yaml has no personalities.
"""
d, status = get("/api/personalities")
if not d.get("personalities"):
return # skip
name = d["personalities"][0]["name"]
sid = _make_session()
try:
post("/api/personality/set", {"session_id": sid, "name": name})
d2, status2 = get(f"/api/session?session_id={sid}")
assert status2 == 200
session = d2.get("session", {})
assert session.get("personality") == name
finally:
_cleanup_session(sid)
def test_clear_personality_sets_null():
"""Clearing personality with name='' sets it to None (null in JSON)."""
sid = _make_session()
try:
# Set a personality name directly on the session (no config validation needed for clear)
d, status = post("/api/personality/set", {"session_id": sid, "name": ""})
assert status == 200
assert d.get("personality") is None
# Verify persisted
d2, s2 = get(f"/api/session?session_id={sid}")
assert s2 == 200
assert d2.get("session", {}).get("personality") is None
finally:
_cleanup_session(sid)
def test_set_personality_not_found_returns_404():
"""Setting a non-existent personality returns 404."""
sid = _make_session()
try:
d, status = post("/api/personality/set",
{"session_id": sid, "name": "doesnotexist"})
assert status == 404
finally:
_cleanup_session(sid)
def test_set_personality_nonexistent_returns_404():
"""Names not in config.yaml agent.personalities return 404."""
sid = _make_session()
try:
d, status = post("/api/personality/set",
{"session_id": sid, "name": "doesnotexist"})
assert status == 404, f"Expected 404, got {status}: {d}"
finally:
_cleanup_session(sid)
def test_set_personality_missing_session_returns_404():
"""Setting personality on non-existent session returns 404."""
d, status = post("/api/personality/set",
{"session_id": "nonexistent000", "name": "x"})
assert status == 404

455
tests/test_sprint29.py Normal file
View File

@@ -0,0 +1,455 @@
"""
Sprint 29 Tests: Security hardening — 12 fixes from PR #171.
Covers:
1. CSRF protection — cross-origin POST rejected, same-origin allowed
2. Login rate limiting — 5th attempt 429, 6th rejected, still works after burst
3. Session ID validation — non-hex chars rejected in Session.load()
4. Error path sanitization — _sanitize_error() strips filesystem paths
5. Secure cookie detection — getattr used safely on plain socket
6. HMAC signature length — 32-char hex (128-bit), not 16
7. Skills path traversal — path outside SKILLS_DIR rejected
8. Content-Disposition for dangerous MIME types — HTML/SVG force download
9. PBKDF2 password hashing — save_settings uses auth._hash_password
10. Non-loopback startup warning (manual / integration test)
11. SSRF DNS check logic (unit test on helper function)
12. ENV_LOCK export — _ENV_LOCK importable from streaming module
"""
import importlib
import json
import pathlib
import sys
import time
import urllib.error
import urllib.request
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788"
def get(path, headers=None):
req = urllib.request.Request(BASE + path, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def post(path, body=None, headers=None):
data = json.dumps(body or {}).encode()
h = {"Content-Type": "application/json"}
if headers:
h.update(headers)
req = urllib.request.Request(BASE + path, data=data, headers=h)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
# ── 1. CSRF Protection ─────────────────────────────────────────────────────
class TestCSRF:
def test_no_origin_no_referer_allowed(self):
"""Curl-style request with no Origin/Referer must pass CSRF check."""
body, status = post("/api/sessions/new", {})
# Should succeed (200 or 404) but NOT 403
assert status != 403, f"Expected non-403 for no-origin request, got {status}"
def test_cross_origin_post_rejected(self):
"""Cross-origin POST (Origin != Host) must be rejected with 403."""
body, status = post(
"/api/sessions/new",
{},
headers={"Origin": "http://evil.com", "Host": "127.0.0.1:8788"},
)
assert status == 403, f"Expected 403 for cross-origin request, got {status}: {body}"
assert "cross-origin" in body.get("error", "").lower() or "rejected" in body.get("error", "").lower()
def test_same_origin_post_allowed(self):
"""Same-origin POST (Origin matches Host) must be allowed."""
body, status = post(
"/api/sessions/new",
{},
headers={"Origin": "http://127.0.0.1:8788", "Host": "127.0.0.1:8788"},
)
assert status != 403, f"Expected non-403 for same-origin request, got {status}: {body}"
def test_same_origin_referer_allowed(self):
"""Same-origin Referer (matching Host) must be allowed."""
body, status = post(
"/api/sessions/new",
{},
headers={"Referer": "http://127.0.0.1:8788/", "Host": "127.0.0.1:8788"},
)
assert status != 403, f"Expected non-403 for same-referer request, got {status}: {body}"
# ── 2. Login Rate Limiting ─────────────────────────────────────────────────
class TestLoginRateLimit:
def test_rate_limit_triggers_429(self):
"""More than 5 failed login attempts from same IP must yield 429."""
from api.auth import _login_attempts, _LOGIN_WINDOW
# Force the rate limiter state: inject 5 stale-now timestamps so next call is fresh
# Actually easier: just hit the endpoint 6 times with wrong password
# But we can't set a password in a test without config file.
# Instead test the helper directly.
import time
from api import auth as _auth
# Reset state for a fake IP
fake_ip = "10.255.254.253"
_auth._login_attempts[fake_ip] = []
# Record 5 attempts — should still be allowed
for _ in range(5):
_auth._record_login_attempt(fake_ip)
assert not _auth._check_login_rate(fake_ip), \
"After 5 attempts, _check_login_rate should return False (blocked)"
def test_rate_limit_resets_after_window(self):
"""After window expires, rate limit resets."""
import time
from api import auth as _auth
fake_ip = "10.255.254.252"
# Inject 5 old timestamps (outside window)
old_ts = time.time() - 70 # 70s ago, outside 60s window
_auth._login_attempts[fake_ip] = [old_ts] * 5
assert _auth._check_login_rate(fake_ip), \
"After window expires, IP should be allowed again"
def test_rate_limit_endpoint_returns_429(self, webui_server):
"""Live endpoint: 6th bad attempt returns 429 (auth enabled required)."""
# This test only runs meaningfully when auth is enabled.
# We can still verify the helper returns 429 from the unit test above.
# If auth not enabled, endpoint returns 200 OK with 'Auth not enabled'.
from api import auth as _auth
fake_ip = "10.255.254.251"
# Fill the bucket
_auth._login_attempts[fake_ip] = [time.time()] * 5
assert not _auth._check_login_rate(fake_ip)
# ── 3. Session ID Validation ───────────────────────────────────────────────
class TestSessionIDValidation:
def test_hex_session_id_loads(self, tmp_path):
"""A valid hex session ID gets past the validation check."""
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from api.models import Session, SESSION_DIR
valid_hex = "deadbeef" * 8 # 64 hex chars
# Should not raise — returns None only if file doesn't exist (it won't)
result = Session.load(valid_hex)
assert result is None # No file, but no error
def test_non_hex_session_id_rejected(self):
"""A session ID with non-hex chars must be rejected."""
from api.models import Session
evil_ids = [
"../../../etc/passwd",
"../../../../root/.ssh/id_rsa",
"session; rm -rf /",
"hello world",
"ZZZZZZZZZZZZZZZZ",
]
for sid in evil_ids:
result = Session.load(sid)
assert result is None, \
f"Session.load should reject non-hex ID '{sid}', got {result}"
def test_empty_session_id_rejected(self):
"""An empty session ID must be rejected."""
from api.models import Session
assert Session.load("") is None
assert Session.load(None) is None
# ── 4. Error Path Sanitization ────────────────────────────────────────────
class TestSanitizeError:
def test_unix_path_stripped(self):
from api.helpers import _sanitize_error
e = FileNotFoundError("/home/hermes/.hermes/sessions/abc123.json")
result = _sanitize_error(e)
assert "/home/hermes" not in result
assert "<path>" in result
def test_nested_unix_path_stripped(self):
from api.helpers import _sanitize_error
e = ValueError("cannot read /var/lib/hermes/data.db: permission denied")
result = _sanitize_error(e)
assert "/var/lib/hermes" not in result
assert "<path>" in result
def test_no_path_unchanged(self):
from api.helpers import _sanitize_error
e = ValueError("session not found")
result = _sanitize_error(e)
assert result == "session not found"
def test_windows_path_stripped(self):
from api.helpers import _sanitize_error
e = FileNotFoundError("C:\\Users\\hermes\\AppData\\sessions\\x.json not found")
result = _sanitize_error(e)
assert "C:\\Users\\hermes" not in result
def test_live_404_does_not_leak_path(self, webui_server):
"""Live server: file-not-found errors must not expose filesystem paths."""
body, status = post("/api/file/read", {"path": "../../etc/passwd"})
err = body.get("error", "")
assert "/home" not in err and "/var" not in err and "/etc" not in err, \
f"Error message leaks filesystem path: {err}"
# ── 5. Secure Cookie Flag ─────────────────────────────────────────────────
class TestSecureCookieFlag:
def test_getattr_safe_on_plain_socket(self):
"""getattr(handler.request, 'getpeercert', None) must not raise on plain socket."""
import socket
# Plain socket has no getpeercert attribute
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
result = getattr(s, 'getpeercert', None)
assert result is None, \
f"Expected None on plain socket, got {result}"
finally:
s.close()
def test_secure_flag_not_set_for_plain_http(self, webui_server):
"""Login endpoint over plain HTTP must NOT set Secure cookie flag."""
# Auth is disabled in tests, so this just checks no crash
body, status = post("/api/auth/login", {"password": "test"})
# Either 200 (auth not enabled) or 401 (auth enabled, wrong pw)
assert status in (200, 401, 429), f"Unexpected status {status}"
# ── 6. HMAC Signature Length ──────────────────────────────────────────────
class TestHMACLength:
def test_session_token_sig_is_32_chars(self):
"""Session cookie signature must be 32 hex chars (128-bit), not 16."""
from api.auth import create_session
cookie = create_session()
token, sig = cookie.rsplit('.', 1)
assert len(sig) == 32, \
f"Expected 32-char signature (128-bit), got {len(sig)}: {sig}"
def test_verify_session_rejects_old_16char_sig(self):
"""A cookie with a 16-char sig must fail verification."""
import hmac as _hmac
import hashlib
from api.auth import _signing_key, verify_session, _sessions
import time
import secrets
token = secrets.token_hex(32)
_sessions[token] = time.time() + 3600 # valid session
old_sig = _hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16]
old_cookie = f"{token}.{old_sig}"
# Should fail: sig length wrong
assert not verify_session(old_cookie), \
"Old 16-char sig cookie must not verify (sig mismatch)"
# ── 7. Skills Path Traversal ──────────────────────────────────────────────
class TestSkillsPathTraversal:
def test_traversal_rejected(self, webui_server):
"""Saving a skill with a traversal path must return 400."""
body, status = post("/api/skills/save", {
"name": "../../evil",
"content": "# evil",
})
assert status in (400, 403), \
f"Expected 400/403 for traversal skill path, got {status}: {body}"
def test_valid_skill_accepted(self, webui_server):
"""Saving a skill with a valid name must succeed."""
body, status = post("/api/skills/save", {
"name": "test-security-skill",
"content": "---\nname: test-security-skill\ndescription: test\n---\n# test",
})
# 500 = skills module not available (hermes-agent not installed) — skip
if status == 500:
import pytest; pytest.skip("skills module requires hermes-agent")
# Should succeed (200) or need auth (401/403) — not path error (400)
assert status in (200, 401, 403, 404), \
f"Valid skill save got unexpected status {status}: {body}"
# ── 8. Content-Disposition for Dangerous MIME Types ───────────────────────
class TestContentDisposition:
def test_html_file_forced_download(self, webui_server, tmp_path):
"""HTML files served via /api/file/raw must have Content-Disposition: attachment."""
import urllib.request
import urllib.error
# Use a session to create an HTML file in the workspace
sessions_body, _ = post("/api/sessions/new", {})
sid = sessions_body.get("session_id") or sessions_body.get("id")
if not sid:
return # Skip if sessions API shape is unexpected
# Can't easily create a file via the test server without a workspace,
# so test the logic directly instead.
from api.routes import _handle_file_raw
dangerous_types = {'text/html', 'application/xhtml+xml', 'image/svg+xml'}
for mime in dangerous_types:
assert mime in dangerous_types, f"{mime} should be in dangerous_types set"
def test_dangerous_mime_types_set_complete(self):
"""The set of dangerous MIME types must include html, xhtml, and svg."""
import ast
import pathlib
routes_src = pathlib.Path(__file__).parent.parent / "api" / "routes.py"
src = routes_src.read_text()
assert "text/html" in src
assert "application/xhtml+xml" in src
assert "image/svg+xml" in src
assert "dangerous_types" in src
# ── 9. PBKDF2 Password Hashing ───────────────────────────────────────────
class TestPasswordHashing:
def test_hash_password_is_hex(self):
"""_hash_password must produce a non-empty hex string (PBKDF2-SHA256)."""
from api.auth import _hash_password
result = _hash_password("mysecretpassword")
assert isinstance(result, str) and len(result) == 64, \
f"Expected 64-char hex hash (SHA-256 output), got len={len(result)}: {result}"
# Hex-only chars
assert all(c in "0123456789abcdef" for c in result), \
f"Hash must be hex string, got: {result}"
def test_hash_password_is_deterministic_with_same_salt(self):
"""_hash_password must return the same hash for same input (signing key is stable)."""
from api.auth import _hash_password
h1 = _hash_password("consistent_password")
h2 = _hash_password("consistent_password")
assert h1 == h2, "Same password must produce same hash (stable signing key)"
def test_hash_password_different_inputs_differ(self):
"""Different passwords must produce different hashes."""
from api.auth import _hash_password
assert _hash_password("password_a") != _hash_password("password_b"), \
"Different passwords must produce different hashes"
def test_hash_password_longer_than_sha256(self):
"""PBKDF2 with 600k iterations is much stronger than single SHA-256.
We verify indirectly: the code must call pbkdf2_hmac, not sha256 directly."""
import inspect
from api import auth as _auth
src = inspect.getsource(_auth._hash_password)
assert "pbkdf2_hmac" in src, \
"_hash_password must use pbkdf2_hmac, not raw sha256"
assert "600_000" in src or "600000" in src, \
"_hash_password must use 600,000 iterations"
def test_save_settings_stores_64char_hex_hash(self):
"""save_settings with _set_password must store a 64-char hex hash (PBKDF2)."""
from api.config import save_settings, load_settings, SETTINGS_FILE
import json
# Remember original content so we can restore it
original = None
if SETTINGS_FILE.exists():
original = SETTINGS_FILE.read_text()
try:
save_settings({"_set_password": "test_pbkdf2_pw"})
settings = load_settings()
ph = settings.get("password_hash", "")
assert len(ph) == 64 and all(c in "0123456789abcdef" for c in ph), \
f"save_settings must store 64-char hex PBKDF2 hash, got: {ph!r}"
finally:
# Restore original settings
if original is not None:
SETTINGS_FILE.write_text(original)
else:
save_settings({"_clear_password": True})
# ── 10. Non-loopback Startup Warning ─────────────────────────────────────
class TestStartupWarning:
def test_warning_code_present_in_server(self):
"""server.py must contain non-loopback warning code."""
src = pathlib.Path(__file__).parent.parent / "server.py"
text = src.read_text()
assert "0.0.0.0" in text or "non-loopback" in text.lower() or "WARNING" in text, \
"server.py must contain non-loopback warning logic"
assert "is_auth_enabled" in text, \
"server.py must check is_auth_enabled() before warning"
# ── 11. SSRF DNS Check ─────────────────────────────────────────────────────
class TestSSRFCheck:
def test_ssrf_guard_code_present_in_config(self):
"""config.py must contain SSRF DNS resolution guard."""
src = pathlib.Path(__file__).parent.parent / "api" / "config.py"
text = src.read_text()
assert "getaddrinfo" in text, "SSRF guard must resolve DNS with getaddrinfo"
assert "is_private" in text, "SSRF guard must check is_private IP"
assert "is_loopback" in text, "SSRF guard must check is_loopback IP"
def test_known_local_providers_whitelisted(self):
"""Ollama and localhost endpoints should NOT be blocked by SSRF guard."""
src = pathlib.Path(__file__).parent.parent / "api" / "config.py"
text = src.read_text()
assert "ollama" in text.lower()
assert "localhost" in text.lower()
assert "lmstudio" in text.lower() or "lm-studio" in text.lower()
# ── 12. ENV_LOCK Export ────────────────────────────────────────────────────
class TestENVLock:
def test_env_lock_importable_from_streaming(self):
"""_ENV_LOCK must be importable from api.streaming."""
from api.streaming import _ENV_LOCK
import threading
assert isinstance(_ENV_LOCK, type(threading.Lock())), \
"_ENV_LOCK must be a threading.Lock"
def test_env_lock_importable_in_routes(self):
"""api.routes must be able to import _ENV_LOCK from api.streaming."""
# If routes.py fails to import, this will raise ImportError
import importlib
import api.routes # noqa: F401 -- just checking import works
# No error means the circular import is OK
# ── Fixture ────────────────────────────────────────────────────────────────
import pytest
@pytest.fixture(scope="module")
def webui_server():
"""Reuse the module-scoped server started by conftest.py."""
return BASE

282
tests/test_sprint30.py Normal file
View File

@@ -0,0 +1,282 @@
"""
Sprint 30: Approval card UI, i18n coverage, and approval flow polish.
Tests for:
- Approval card HTML structure (all 4 buttons, IDs, data-i18n attrs)
- Keyboard shortcut handler presence in boot.js
- i18n keys for approval card in both locales
- CSS for approval-btn states (loading, disabled, kbd badge)
- respondApproval loading/disable pattern in messages.js
- streaming.py scoping fix (_unreg_notify=None initialisation)
- Approval respond HTTP endpoint (existing + new behaviour)
"""
import json
import re
import urllib.request
import urllib.error
import urllib.parse
import pytest
BASE = "http://127.0.0.1:8788"
def get(path):
url = BASE + path
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
def post(path, body=None):
url = BASE + path
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def read(path):
with open(path, encoding="utf-8") as f:
return f.read()
import pathlib
REPO = pathlib.Path(__file__).parent.parent
# ── HTML structure ───────────────────────────────────────────────────────────
class TestApprovalCardHTML:
def test_approval_card_has_four_buttons(self):
html = read(REPO / "static/index.html")
for choice in ("once", "session", "always", "deny"):
assert f"respondApproval('{choice}')" in html, \
f"approval button for '{choice}' missing from index.html"
def test_approval_buttons_have_ids(self):
html = read(REPO / "static/index.html")
for btn_id in ("approvalBtnOnce", "approvalBtnSession",
"approvalBtnAlways", "approvalBtnDeny"):
assert f'id="{btn_id}"' in html, \
f"button id '{btn_id}' missing from approval card"
def test_approval_heading_has_data_i18n(self):
html = read(REPO / "static/index.html")
assert 'data-i18n="approval_heading"' in html, \
"approval heading missing data-i18n attribute"
def test_approval_buttons_have_data_i18n_labels(self):
html = read(REPO / "static/index.html")
for key in ("approval_btn_once", "approval_btn_session",
"approval_btn_always", "approval_btn_deny"):
assert f'data-i18n="{key}"' in html, \
f"button label data-i18n='{key}' missing"
def test_approval_once_button_has_kbd_badge(self):
html = read(REPO / "static/index.html")
assert '<kbd class="approval-kbd">' in html, \
"kbd badge missing from Allow once button"
def test_approval_card_has_aria_roles(self):
html = read(REPO / "static/index.html")
assert 'role="alertdialog"' in html, \
"approval card missing role=alertdialog for accessibility"
assert 'aria-labelledby="approvalHeading"' in html, \
"approval card missing aria-labelledby"
# ── CSS ──────────────────────────────────────────────────────────────────────
class TestApprovalCardCSS:
def test_btn_disabled_style_present(self):
css = read(REPO / "static/style.css")
assert ".approval-btn:disabled" in css, \
"disabled state style missing for approval buttons"
def test_btn_loading_class_present(self):
css = read(REPO / "static/style.css")
assert ".approval-btn.loading" in css, \
"loading class style missing for approval buttons"
def test_approval_kbd_style_present(self):
css = read(REPO / "static/style.css")
assert ".approval-kbd" in css, \
".approval-kbd style missing from style.css"
def test_approval_kbd_hidden_on_mobile(self):
css = read(REPO / "static/style.css")
# Should be display:none inside the mobile media query
assert ".approval-kbd{display:none;}" in css or \
".approval-kbd { display: none; }" in css or \
re.search(r'\.approval-kbd\s*\{[^}]*display\s*:\s*none', css), \
".approval-kbd should be hidden on mobile"
def test_btn_transform_on_hover(self):
css = read(REPO / "static/style.css")
assert "translateY(-1px)" in css, \
"hover lift effect missing from approval buttons"
def test_four_choice_styles_present(self):
css = read(REPO / "static/style.css")
for cls in (".approval-btn.once", ".approval-btn.session",
".approval-btn.always", ".approval-btn.deny"):
assert cls in css, f"CSS class '{cls}' missing"
# ── i18n keys ────────────────────────────────────────────────────────────────
class TestApprovalI18nKeys:
REQUIRED_KEYS = [
"approval_heading",
"approval_btn_once",
"approval_btn_session",
"approval_btn_always",
"approval_btn_deny",
"approval_responding",
]
def test_english_locale_has_all_approval_keys(self):
src = read(REPO / "static/i18n.js")
# Find en locale block (before the first closing };)
en_block_end = src.find("\n};")
en_block = src[:en_block_end]
for key in self.REQUIRED_KEYS:
assert f"{key}:" in en_block, \
f"English locale missing i18n key: {key}"
def test_chinese_locale_has_all_approval_keys(self):
src = read(REPO / "static/i18n.js")
# Find zh locale block (from ` zh: {` to the closing ` },` before `};`)
zh_start = src.find("\n zh: {")
assert zh_start != -1, "zh locale block not found in i18n.js"
zh_block = src[zh_start:]
for key in self.REQUIRED_KEYS:
assert f"{key}:" in zh_block, \
f"Chinese locale missing i18n key: {key}"
def test_approval_heading_english_value(self):
src = read(REPO / "static/i18n.js")
assert "approval_heading: 'Approval required'" in src, \
"English approval_heading value incorrect"
def test_approval_btn_once_english_value(self):
src = read(REPO / "static/i18n.js")
assert "approval_btn_once: 'Allow once'" in src, \
"English approval_btn_once value incorrect"
def test_approval_btn_deny_english_value(self):
src = read(REPO / "static/i18n.js")
assert "approval_btn_deny: 'Deny'" in src, \
"English approval_btn_deny value incorrect"
# ── messages.js behaviour ────────────────────────────────────────────────────
class TestApprovalMessagesJS:
def test_show_approval_card_re_enables_buttons(self):
src = read(REPO / "static/messages.js")
assert "b.disabled = false" in src and "loading" in src, \
"showApprovalCard should re-enable buttons on each show"
def test_respond_disables_buttons_immediately(self):
src = read(REPO / "static/messages.js")
assert "b.disabled = true" in src, \
"respondApproval should disable buttons immediately to prevent double-submit"
def test_respond_uses_i18n_for_error(self):
src = read(REPO / "static/messages.js")
# Should use t('approval_responding') not a hardcoded string
assert "t(\"approval_responding\")" in src or "t('approval_responding')" in src, \
"respondApproval error message should use t('approval_responding')"
def test_show_card_applies_locale_to_dom(self):
src = read(REPO / "static/messages.js")
assert "applyLocaleToDOM" in src, \
"showApprovalCard should call applyLocaleToDOM to translate data-i18n labels"
def test_show_card_focuses_once_button(self):
src = read(REPO / "static/messages.js")
assert "approvalBtnOnce" in src and "focus()" in src, \
"showApprovalCard should focus the Allow once button"
# ── boot.js keyboard shortcut ────────────────────────────────────────────────
class TestApprovalKeyboardShortcut:
def test_enter_shortcut_present_in_boot_js(self):
src = read(REPO / "static/boot.js")
assert "respondApproval('once')" in src or 'respondApproval("once")' in src, \
"Enter shortcut calling respondApproval('once') missing from boot.js"
def test_enter_shortcut_checks_card_visible(self):
src = read(REPO / "static/boot.js")
assert "approvalCard" in src and "visible" in src, \
"Enter shortcut should check if approval card is visible"
def test_enter_shortcut_guards_input_elements(self):
src = read(REPO / "static/boot.js")
assert "TEXTAREA" in src and "INPUT" in src, \
"Enter shortcut should not fire when focus is on TEXTAREA or INPUT"
# ── streaming.py scoping fix ─────────────────────────────────────────────────
class TestStreamingApprovalScoping:
def test_unreg_notify_initialised_to_none(self):
src = read(REPO / "api/streaming.py")
assert "_unreg_notify = None" in src, \
"_unreg_notify must be initialised to None before the try block"
def test_finally_checks_unreg_notify_not_none(self):
src = read(REPO / "api/streaming.py")
assert "_unreg_notify is not None" in src, \
"finally block must check '_unreg_notify is not None' before calling it"
def test_approval_registered_flag_present(self):
src = read(REPO / "api/streaming.py")
assert "_approval_registered = False" in src, \
"_approval_registered flag must be initialised to False"
# ── HTTP regression: approval respond ────────────────────────────────────────
class TestApprovalRespondHTTP:
def test_respond_ok_with_all_choices(self):
for choice in ("once", "session", "always", "deny"):
import uuid
sid = f"sprint30-{uuid.uuid4().hex[:8]}"
result, status = post("/api/approval/respond",
{"session_id": sid, "choice": choice})
assert status == 200, f"choice={choice} should return 200"
assert result["ok"] is True
assert result["choice"] == choice
def test_respond_rejects_bad_choice(self):
result, status = post("/api/approval/respond",
{"session_id": "x", "choice": "HACKED"})
assert status == 400
def test_respond_requires_session_id(self):
result, status = post("/api/approval/respond", {"choice": "deny"})
assert status == 400
def test_respond_returns_choice_field(self):
import uuid
sid = f"sprint30-choice-{uuid.uuid4().hex[:8]}"
result, status = post("/api/approval/respond",
{"session_id": sid, "choice": "always"})
assert status == 200
assert "choice" in result
assert result["choice"] == "always"