Compare commits

...

85 Commits

Author SHA1 Message Date
nesquena-hermes
e3c85624d9 docs: v0.43.0 release — auto-install agent deps, session ID validator, test suite isolation fix (571 tests) (#217)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:10:02 -07:00
nesquena-hermes
ed9023a431 fix: wire auto_install_agent_deps into server.py startup (#216)
* fix: wire auto_install_agent_deps into server.py startup; add api/startup.py to ARCHITECTURE.md

* fix(tests): kill stale process on test port before server start in conftest

Stale servers left by QA harness runs (ports 8792/8793 etc.) or prior
test sessions could interfere with conftest starting its own server on
TEST_PORT (8788). If the port was already occupied, _wait_for_server
hit the wrong server and tests got unexpected 404s/500s, failing
non-deterministically — the 'conftest isolation issue' seen this session.

Fix: run fuser -k on TEST_PORT before launching the new server process,
with a 0.5s sleep for port release. The full suite now runs 571/571
reliably regardless of what other servers were previously active.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 00:56:07 -07:00
nesquena-hermes
e59fedd351 feat: auto-install missing agent deps on startup (#215)
* feat: auto-install missing agent deps on startup

* fix: patch HERMES_HOME in test_skips_when_agent_dir_missing to prevent real agent fallback

The test patched HERMES_WEBUI_AGENT_DIR to a nonexistent path but left
HERMES_HOME unpatched. In the full test suite HERMES_HOME resolves to the
real hermes agent dir, causing the fallback in _agent_dir() to find and
use it — making auto_install_agent_deps() call pip instead of returning
False. Fix: also patch HERMES_HOME to a nonexistent dir in env_overrides.

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 00:42:02 -07:00
nesquena-hermes
9a5435176d fix: broaden session ID validator to support new hermes-agent format (#212)
* fix: broaden session ID validator to support new hermes-agent format

* test: add more path traversal evil IDs to session validator test

Add null byte, backslash, forward slash, and dot-extension variants
to the rejected session ID test to cover additional attack vectors.

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

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:00:02 -07:00
nesquena-hermes
31281a6025 docs: v0.42.2 release — CSP unsafe-inline fix (564 tests) (#210)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:08:30 -07:00
nesquena-hermes
cc8cbc4d3f fix(security): add unsafe-inline and CDN allowlist to CSP script-src (#209)
The CSP script-src 'self' policy blocked all inline onclick= event handlers
in index.html (55+ handlers including toggleSettings(), switchPanel(),
filterSessions() etc.), making the settings panel, sidebar navigation, and
most interactive UI elements non-functional.

Also restores https://cdn.jsdelivr.net to both script-src and style-src
(required for Mermaid.js dynamic load in ui.js and Prism.js static load
in index.html). This was present in the original PR #197 merge but was
dropped in the v0.42.1 commit.

script-src additions:
- 'unsafe-inline': required for onclick=/oninput=/onchange= attributes
- https://cdn.jsdelivr.net: Mermaid (dynamic) and Prism (static with SRI)

style-src: retains 'unsafe-inline' + cdn.jsdelivr.net (Prism CSS)

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:07:51 -07:00
nesquena-hermes
0e5e465ea0 fix: i18n button text stripping and German translation corrections (v0.42.1)
Some checks failed
Release & Docker / release (push) Has been cancelled
Three sidebar buttons (+ New job/skill/profile) and three suggestion
buttons had data-i18n on the outer element, causing applyLocaleToDOM
to strip the + prefix and emoji characters when switching locales.
Fixed by wrapping only the label text in a <span data-i18n=...>.

Also corrects German translations:
- cancelling: imperative -> progressive (Wird abgebrochen...)
- editing: first-person verb -> noun (Bearbeitung)
- empty_subtitle: add missing 'explore files' clause
- settings_desc_check_updates: add git fetch detail
- settings_desc_cli_sessions: add 'continue the conversation' clause

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 19:04:48 -07:00
nesquena-hermes
a92e21553d docs: v0.42.0 release — German i18n, custom provider routing, phantom Custom group fix (564 tests) (#207)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:44:04 -07:00
David Schuchert
06f46439c0 feat: add German translation and make UI elements translatable (#190)
Co-authored-by: David Work <davidwork@MBP-von-David.fritz.box>
2026-04-09 18:35:23 -07:00
nesquena-hermes
e68c1b92a4 fix: do not build phantom Custom group when active provider is set (#206)
* fix: do not build phantom "Custom" group when active provider is set

When model.provider is a real provider (e.g. openai-codex) and model.base_url
is configured, hermes_cli reports 'custom' as an authenticated provider. The
WebUI model picker was building a separate "Custom" group for it and parking
the configured default_model there instead of under the active provider's
group — diverging from the TUI which correctly shows the model under its
configured provider.

Two fixes in api/config.py get_available_models():

1. Discard 'custom' from detected_providers when active_provider is set and
   isn't 'custom' itself. The base_url belongs to the active provider.

2. Replace the substring-based default-model injection check with an exact
   match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in
   g.get('provider', '').lower()` silently failed for hyphenated IDs like
   'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space),
   falling through to groups[0] and landing the model in the alphabetical
   first group instead.

Adds two regression tests in tests/test_model_resolver.py covering both
conditions.

* fix: do not build phantom Custom group when active provider is set

Two bugs in get_available_models():

1. Phantom Custom group: hermes_cli reports 'custom' as authenticated
whenever model.base_url is set. With provider=openai-codex + base_url,
detected_providers contained both 'openai-codex' and 'custom', producing
a duplicate group. Fixed by discarding 'custom' from detected_providers
when the active provider is any real named provider.

2. Hyphen/space mismatch in default_model injection: the substring check
'openai-codex' in 'openai codex' is False (hyphen vs space), causing the
default model to fall through to groups[0] (alphabetically first provider)
instead of the active provider group. Fixed by using _PROVIDER_DISPLAY
for exact display-name comparison.

Also fixes test helper _available_models_with_full_cfg to clear model env
vars during the call, preventing real hermes profile env from leaking into
the test assertions.

---------

Co-authored-by: mbac <marco.baciarello@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:33:24 -07:00
sean
fb19c7ea1f fix: route slash-based custom provider models correctly (#189)
Co-authored-by: smurmann <smurmann@users.noreply.github.com>
2026-04-09 18:23:40 -07:00
nesquena-hermes
cb069794dd docs: v0.41.0 release — TLS, CSP, session memory leak, slow-client timeout, update checker, CLI file browser (561 tests) (#205)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 18:20:07 -07:00
Cyprian Kowalczyk
be92e59bdb fix: support CLI sessions in /api/list file browser (#204)
* feat: optional HTTPS/TLS support via cert and key env vars

Add optional HTTPS support controlled by two env vars:
  HERMES_WEBUI_TLS_CERT=/path/to/cert.pem
  HERMES_WEBUI_TLS_KEY=/path/to/key.pem

- Wraps server socket with ssl.SSLContext (min TLSv1.2)
- Dynamic scheme detection for startup messages (http:// vs https://)
- Graceful fallback to HTTP if cert loading fails — server never crashes
  due to bad TLS config, just prints a warning and continues
- Auth cookie Secure flag already set when HTTPS is detected via getpeercert
- 6 end-to-end tests: config flags, HTTPS handshake, HTTP still works,
  fallback on bad paths

Addresses #191 (HTTPS support issue).

* fix: use current branch upstream for update checks, not repo default branch

The update checker in api/updates.py always compared HEAD against
origin/master (or origin/main), which produced false 'N updates
available' alerts when the user is on a feature branch and master has
moved forward with unrelated commits.

Now uses git rev-parse --abbrev-ref @{upstream} to get the current
branch's tracking branch for both the behind-count check and the
apply-update pull command. Falls back to the default branch if no
upstream is set (brand-new local branch with no tracking config).

Fixes #200.

* fix: support CLI sessions in /api/list file browser

_handle_list_dir() only checked WebUI in-memory sessions, returning
'Session not found' for CLI sessions imported from the agent's state.db.
Now falls back to get_cli_sessions() to find the workspace path for
CLI sessions that aren't loaded in WebUI memory.

Fixes: workspace pane showing empty for CLI sessions.
2026-04-09 18:18:38 -07:00
Cyprian Kowalczyk
f90be60e31 fix: use current branch upstream for update checks instead of default branch (#201)
* feat: optional HTTPS/TLS support via cert and key env vars

Add optional HTTPS support controlled by two env vars:
  HERMES_WEBUI_TLS_CERT=/path/to/cert.pem
  HERMES_WEBUI_TLS_KEY=/path/to/key.pem

- Wraps server socket with ssl.SSLContext (min TLSv1.2)
- Dynamic scheme detection for startup messages (http:// vs https://)
- Graceful fallback to HTTP if cert loading fails — server never crashes
  due to bad TLS config, just prints a warning and continues
- Auth cookie Secure flag already set when HTTPS is detected via getpeercert
- 6 end-to-end tests: config flags, HTTPS handshake, HTTP still works,
  fallback on bad paths

Addresses #191 (HTTPS support issue).

* fix: use current branch upstream for update checks, not repo default branch

The update checker in api/updates.py always compared HEAD against
origin/master (or origin/main), which produced false 'N updates
available' alerts when the user is on a feature branch and master has
moved forward with unrelated commits.

Now uses git rev-parse --abbrev-ref @{upstream} to get the current
branch's tracking branch for both the behind-count check and the
apply-update pull command. Falls back to the default branch if no
upstream is set (brand-new local branch with no tracking config).

Fixes #200.
2026-04-09 18:10:11 -07:00
Cyprian Kowalczyk
011034dc71 feat: optional HTTPS/TLS support via cert and key env vars (#199)
Add optional HTTPS support controlled by two env vars:
  HERMES_WEBUI_TLS_CERT=/path/to/cert.pem
  HERMES_WEBUI_TLS_KEY=/path/to/key.pem

- Wraps server socket with ssl.SSLContext (min TLSv1.2)
- Dynamic scheme detection for startup messages (http:// vs https://)
- Graceful fallback to HTTP if cert loading fails — server never crashes
  due to bad TLS config, just prints a warning and continues
- Auth cookie Secure flag already set when HTTPS is detected via getpeercert
- 6 end-to-end tests: config flags, HTTPS handshake, HTTP still works,
  fallback on bad paths

Addresses #191 (HTTPS support issue).
2026-04-09 18:08:29 -07:00
Cyprian Kowalczyk
392bc5df6e fix: add Content-Security-Policy and Permissions-Policy headers (#197)
Add CSP and Permissions-Policy headers to _security_headers() for
defense-in-depth against XSS and unwanted browser feature access.

CSP policy:
  default-src 'self' — only load resources from same origin
  script-src 'self' — prevent inline/remote script injection
  style-src 'self' 'unsafe-inline' — allow themes (inline styles)
  img-src 'self' data: — allow workspace images and data URIs
  font-src 'self' data: — allow web fonts
  connect-src 'self' — only allow fetch/XHR to same origin
  base-uri 'self'; form-action 'self' — prevent base/form injection

Permissions-Policy: disable camera, microphone, geolocation.

Addresses #193.
2026-04-09 18:07:07 -07:00
Cyprian Kowalczyk
fdf6ebfbe6 fix(auth): prune expired sessions on every verify to prevent memory leak (#196)
* fix(auth): prune expired sessions on every verify to prevent memory leak

The in-memory _sessions dict accumulated expired tokens indefinitely —
entries were only removed when that specific token was verified. Add a
lazy _prune_expired_sessions() call at the top of verify_session() so
all expired entries are swept during normal traffic.

Addresses #192.

* test(auth): add 8 unit tests for session lifecycle and lazy pruning

Tests verify:
- Fresh session creation and validation
- Expired entries are pruned during verify_session() calls
- Valid sessions are never removed by pruning
- Empty dict is safe for pruning
- Session TTL matches expected 24-hour window
- invalidate_session() actually removes the token
- Invalidating non-existent tokens is safe
2026-04-09 18:05:23 -07:00
Cyprian Kowalczyk
04678b7b6e feat(server): add 30s connection timeout to prevent slow-client thread exhaustion (#198)
Set Handler.timeout = 30. Python's BaseHTTPRequestHandler.setup()
calls self.request.settimeout(timeout), which raises socket.timeout
on idle or slow connections after the configured duration.

This defends against Slowloris-style attacks where a client holds
connections open indefinitely, exhausting threads in ThreadingHTTPServer.
Also recovers threads from crashed clients with hung TCP connections.

Addresses #194.
2026-04-09 18:05:18 -07:00
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
39 changed files with 4240 additions and 363 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

@@ -40,6 +40,7 @@ This makes the code easy to modify from a terminal or by an agent.
models.py Session model + CRUD, per-session profile tracking (~137 lines)
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
routes.py All GET + POST route handlers (~1180 lines)
startup.py Startup helpers: auto_install_agent_deps() (~50 lines)
streaming.py SSE engine, run_agent, cancel, HERMES_HOME save/restore (~236 lines)
upload.py Multipart parser, file upload handler (~78 lines)
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)

View File

@@ -5,6 +5,267 @@
---
## [v0.43.0] — 2026-04-10
### Features
- **Auto-install agent dependencies on startup** (PRs #215 + #216): When `hermes-agent` is found on disk but its Python dependencies are missing (common in Docker deployments where the agent is volume-mounted post-build), `server.py` now calls `api/startup.auto_install_agent_deps()` to install from `requirements.txt` or `pyproject.toml`. Falls back gracefully — failures are logged and never fatal.
### Bug Fixes
- **Session ID validator broadened** (PR #212): `Session.load()` rejected any session ID containing non-hex characters, breaking sessions created by the new hermes-agent format (`YYYYMMDD_HHMMSS_xxxxxx`). Validator now accepts `[0-9a-z_]` while rejecting path traversal patterns (null bytes, slashes, backslashes, dot-extensions).
- **Test suite isolation** (PR #216): `conftest.py` now kills any stale process on the test port (8788) before starting the fixture server. Stale QA harness servers (8792/8793) could occupy 8788 and cause non-deterministic test failures across the full suite.
## [v0.42.2] — 2026-04-10
### Bug Fixes
- **CSP blocking inline event handlers** (PR #209): `script-src 'self'` blocked all 55+ inline `onclick=` handlers in `index.html`, making the settings panel, sidebar navigation, and most interactive controls non-functional. Added `'unsafe-inline'` to `script-src`. Also restores `https://cdn.jsdelivr.net` to `script-src` and `style-src` for Mermaid.js and Prism.js (dropped in v0.42.1).
## [v0.42.1] — 2026-04-11
### Bug Fixes
- **i18n button text stripping** (post-review): Three sidebar buttons (`+ New job`, `+ New skill`, `+ New profile`) and three suggestion buttons had `data-i18n` on the outer element, which caused `applyLocaleToDOM` to replace the entire `textContent` — stripping the `+` prefix and emoji characters on locale switch. Fixed by wrapping only the translatable label text in a `<span data-i18n="...">`.
- **German translation corrections** (post-review): Fixed `cancelling` (imperative → progressive `"Wird abgebrochen…"`), `editing` (first-person verb → noun `"Bearbeitung"`), and completed truncated descriptions for `empty_subtitle`, `settings_desc_check_updates`, and `settings_desc_cli_sessions`.
## [v0.42.0] — 2026-04-10
### Features
- **German translation** (PR #190 by @DavidSchuchert): Complete `de` locale covering all UI strings — settings, commands, sidebar, approval cards. Also extends the i18n system with `data-i18n-title` and `data-i18n-placeholder` attribute support so tooltip text and input placeholders are now translatable. German speech recognition uses `de-DE`.
### Bug Fixes
- **Custom slash-model routing** (PR #189 by @smurmann): Model IDs like `google/gemma-4-26b-a4b` from custom providers (LM Studio, Ollama) were silently misrouted to OpenRouter because of the slash-heuristic. Custom providers now win: entries in `config.yaml → custom_providers` are checked first, so their model IDs route to the correct local endpoint regardless of format.
- **Phantom Custom group in model picker** (PR #191 by @mbac): When `model.provider` was a named provider (e.g. `openai-codex`) and `model.base_url` was set, `hermes_cli` reported `'custom'` as authenticated, producing a duplicate "Custom" group in the dropdown. The real provider's group was missing the configured default model. Fixed by discarding the phantom `custom` entry when a real named provider is active.
- **Hyphen/space model group injection** (PR #191): The "ensure default_model appears" post-pass used `active_provider.lower() in group_name.lower()`, which fails for `openai-codex` vs display name `OpenAI Codex` (hyphen vs space). Now uses `_PROVIDER_DISPLAY` for exact display-name matching.
## [v0.41.0] — 2026-04-10
### Features
- **Optional HTTPS/TLS support** (PR #199): Set `HERMES_WEBUI_TLS_CERT` and
`HERMES_WEBUI_TLS_KEY` env vars to enable HTTPS natively. Uses
`ssl.PROTOCOL_TLS_SERVER` with TLS 1.2 minimum. Gracefully falls back to HTTP
if cert loading fails. No reverse proxy required for LAN/VPN deployments.
### Bug Fixes
- **CSP blocking Mermaid and Prism** (PR #197): Added Content-Security-Policy and
Permissions-Policy headers to every response. CSP allows `cdn.jsdelivr.net` in
`script-src` and `style-src` for Mermaid.js (dynamically loaded) and Prism.js
(statically loaded with SRI integrity hashes). All other external origins blocked.
- **Session memory leak** (PR #196): `api/auth.py` accumulated expired session tokens
indefinitely. Added `_prune_expired_sessions()` called lazily on every
`verify_session()` call. No background thread, no lock contention.
- **Slow-client thread exhaustion** (PR #198): Added `Handler.timeout = 30` to kill
idle/stalled connections before they exhaust the thread pool.
- **False update alerts on feature branches** (PR #201): Update checker compared
`HEAD..origin/master` even when on a feature branch, counting unrelated master
commits as missing updates. Now uses `git rev-parse --abbrev-ref @{upstream}` to
track the current branch's upstream. Falls back to default branch when no upstream
is set.
- **CLI session file browser returning 404** (PR #204): `/api/list` only checked
the WebUI in-memory session dict, so CLI sessions shown in the sidebar always
returned 404 for file browsing. Now falls back to `get_cli_sessions()` — the same
pattern used by `/api/session` GET and `/api/sessions` list.
## [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 +1530,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,16 +104,24 @@ 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}"
def _prune_expired_sessions():
"""Remove all expired session entries to prevent unbounded memory growth."""
now = time.time()
for token in [t for t, exp in _sessions.items() if now > exp]:
_sessions.pop(token, None)
def verify_session(cookie_value) -> bool:
"""Verify a signed session cookie. Returns True if valid and not expired."""
if not cookie_value or '.' not in cookie_value:
return False
_prune_expired_sessions() # lazy cleanup on every verification attempt
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 +185,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

@@ -28,6 +28,11 @@ REPO_ROOT = Path(__file__).parent.parent.resolve()
HOST = os.getenv('HERMES_WEBUI_HOST', '127.0.0.1')
PORT = int(os.getenv('HERMES_WEBUI_PORT', '8787'))
# ── TLS/HTTPS config (optional, env-overridable) ────────────────────────────
TLS_CERT = os.getenv('HERMES_WEBUI_TLS_CERT', '').strip() or None
TLS_KEY = os.getenv('HERMES_WEBUI_TLS_KEY', '').strip() or None
TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None
# ── State directory (env-overridable, never inside repo) ──────────────────────
STATE_DIR = Path(os.getenv(
'HERMES_WEBUI_STATE_DIR',
@@ -296,7 +301,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 +323,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 +368,41 @@ _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-style 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.
Custom OpenAI-compatible endpoints are special: their model IDs often look
like provider/model (for example ``google/gemma-4-26b-a4b``), which would be
mistaken for an OpenRouter model if we only looked at the slash. To avoid
that, first check whether the selected model matches an entry in
config.yaml -> custom_providers and route it through that named custom
provider.
Returns (model, provider, base_url) where provider and base_url may be None.
"""
@@ -389,8 +417,33 @@ def resolve_model_provider(model_id: str) -> tuple:
if not model_id:
return model_id, config_provider, config_base_url
# Custom providers declared in config.yaml should win over slash-based
# OpenRouter heuristics. Their model IDs commonly contain '/' too.
custom_providers = cfg.get('custom_providers', [])
if isinstance(custom_providers, list):
for entry in custom_providers:
if not isinstance(entry, dict):
continue
entry_model = (entry.get('model') or '').strip()
entry_name = (entry.get('name') or '').strip()
entry_base_url = (entry.get('base_url') or '').strip()
if entry_model and entry_name and model_id == entry_model:
provider_hint = 'custom:' + entry_name.lower().replace(' ', '-')
return model_id, provider_hint, entry_base_url or None
# @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 +451,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 +510,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 +591,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 +613,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 +664,30 @@ 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')
# If the user configured a real model.provider, the base_url belongs to
# THAT provider, not to a separate "Custom" group. hermes_cli reports
# 'custom' as authenticated whenever base_url is set, which would otherwise
# build a phantom "Custom" bucket next to the real provider's group. Drop
# it unless the user explicitly chose 'custom' as their active provider.
if active_provider and active_provider != 'custom':
detected_providers.discard('custom')
# 5. Build model groups
if detected_providers:
for pid in sorted(detected_providers):
@@ -581,9 +699,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,26 +734,36 @@ 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:
# Determine which group to inject into
_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. Compare against the
# provider's display name from _PROVIDER_DISPLAY rather than
# doing a substring match on active_provider — substring
# matching breaks on hyphenated provider IDs like 'openai-codex'
# vs display name 'OpenAI Codex' (hyphen vs. space), which
# silently falls through to groups[0] and lands the model in
# the wrong group.
label = default_model.split('/')[-1] if '/' in default_model else default_model
target_display = (
_PROVIDER_DISPLAY.get(active_provider, active_provider or '').lower()
if active_provider else ''
)
injected = False
for g in groups:
if active_provider and active_provider.lower() in g.get('provider', '').lower():
if target_display and g.get('provider', '').lower() == target_display:
g['models'].insert(0, {'id': default_model, 'label': label})
injected = True
break
@@ -676,7 +821,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 +844,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 +865,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()
@@ -30,6 +39,18 @@ def _security_headers(handler):
handler.send_header('X-Content-Type-Options', 'nosniff')
handler.send_header('X-Frame-Options', 'DENY')
handler.send_header('Referrer-Policy', 'same-origin')
handler.send_header(
'Content-Security-Policy',
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"img-src 'self' data:; font-src 'self' data:; connect-src 'self'; "
"base-uri 'self'; form-action 'self'"
)
handler.send_header(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
)
def j(handler, payload, status: int=200) -> None:

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 '0123456789abcdefghijklmnopqrstuvwxyz_' 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)
@@ -738,15 +862,29 @@ def _handle_list_dir(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get('session_id', [''])[0]
if not sid: return bad(handler, 'session_id is required')
try: s = get_session(sid)
except KeyError: return bad(handler, 'Session not found', 404)
try:
s = get_session(sid)
workspace = s.workspace
except KeyError:
# Fallback for CLI sessions not loaded in WebUI memory
try:
cli_meta = None
for cs in get_cli_sessions():
if cs['session_id'] == sid:
cli_meta = cs
break
if not cli_meta:
return bad(handler, 'Session not found', 404)
workspace = cli_meta.get('workspace', '')
except Exception:
return bad(handler, 'Session not found', 404)
try:
return j(handler, {
'entries': list_dir(Path(s.workspace), qs.get('path', ['.'])[0]),
'entries': list_dir(Path(workspace), qs.get('path', ['.'])[0]),
'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 +933,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 +955,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 +1106,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 +1123,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 +1156,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 +1176,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 +1262,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 +1276,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 +1290,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 +1309,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 +1322,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 +1369,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 +1380,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 +1401,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')

46
api/startup.py Normal file
View File

@@ -0,0 +1,46 @@
"""Hermes Web UI -- startup helpers."""
from __future__ import annotations
import os, subprocess, sys
from pathlib import Path
def _agent_dir() -> Path | None:
hermes_home = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes')))
for raw in [os.environ.get('HERMES_WEBUI_AGENT_DIR', '').strip(), str(hermes_home / 'hermes-agent')]:
if not raw:
continue
p = Path(raw).expanduser()
if p.is_dir():
return p.resolve()
return None
def auto_install_agent_deps() -> bool:
agent_dir = _agent_dir()
if agent_dir is None:
print('[!!] Auto-install skipped: agent directory not found.', flush=True)
return False
req_file = agent_dir / 'requirements.txt'
pyproject = agent_dir / 'pyproject.toml'
if req_file.exists():
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', '-r', str(req_file)]
print(f' Installing from {req_file} ...', flush=True)
elif pyproject.exists():
install_args = [sys.executable, '-m', 'pip', 'install', '--quiet', str(agent_dir)]
print(f' Installing from {agent_dir} (pyproject.toml) ...', flush=True)
else:
print('[!!] Auto-install skipped: no requirements.txt or pyproject.toml in agent dir.', flush=True)
return False
try:
result = subprocess.run(install_args, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
print(f'[!!] pip install failed (exit {result.returncode}):', flush=True)
for line in (result.stderr or '').splitlines()[-10:]:
print(f' {line}', flush=True)
return False
print('[ok] pip install completed.', flush=True)
return True
except subprocess.TimeoutExpired:
print('[!!] Auto-install timed out after 120s.', flush=True)
return False
except Exception as e:
print(f'[!!] Auto-install error: {e}', flush=True)
return False

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

@@ -64,22 +64,32 @@ def _check_repo(path, name):
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
branch = _detect_default_branch(path)
# Use the current branch's upstream tracking branch, not the repo default.
# This avoids false "N updates behind" alerts when the user is on a feature
# branch and master/main has moved forward with unrelated commits.
# If no upstream is set (brand-new local branch), fall back to the default branch.
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
# upstream is like "origin/feat/foo" — use it directly in rev-list
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Count commits behind
out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path)
out, ok = _run_git(['rev-list', '--count', f'HEAD..{compare_ref}'], path)
behind = int(out) if ok and out.isdigit() else 0
# Get short SHAs for display
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path)
latest, _ = _run_git(['rev-parse', '--short', compare_ref], path)
return {
'name': name,
'behind': behind,
'current_sha': current,
'latest_sha': latest,
'branch': branch,
'branch': compare_ref,
}
@@ -129,7 +139,14 @@ def _apply_update_inner(target):
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
branch = _detect_default_branch(path)
# Use the current branch's upstream for pull, matching the behaviour
# of _check_repo. Falls back to default branch if no upstream is set.
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Check for dirty working tree
status_out, _ = _run_git(['status', '--porcelain'], path)
@@ -141,7 +158,7 @@ def _apply_update_inner(target):
stashed = True
# Pull with ff-only (no merge commits)
pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30)
pull_out, pull_ok = _run_git(['pull', '--ff-only', compare_ref], path, timeout=30)
if not pull_ok:
if stashed:
_run_git(['stash', 'pop'], path)

View File

@@ -12,9 +12,11 @@ from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j
from api.routes import handle_get, handle_post
from api.startup import auto_install_agent_deps
class Handler(BaseHTTPRequestHandler):
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
server_version = 'HermesWebUI/0.2'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
@@ -61,21 +63,54 @@ 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)
for mod, err in errors.items():
print(f' {mod}: {err}', flush=True)
print(' Agent features may not work correctly.', flush=True)
print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True)
auto_install_agent_deps()
ok, missing, errors = verify_hermes_imports()
if not ok:
print(f'[!!] Still missing after install attempt: {missing}', flush=True)
for mod, err in errors.items():
print(f' {mod}: {err}', flush=True)
print(' Agent features may not work correctly.', flush=True)
else:
print('[ok] Agent dependencies installed successfully.', flush=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
print(f' Hermes Web UI listening on http://{HOST}:{PORT}', flush=True)
# ── TLS/HTTPS setup (optional) ─────────────────────────────────────────
from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
scheme = 'https' if TLS_ENABLED else 'http'
if TLS_ENABLED:
try:
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(TLS_CERT, TLS_KEY)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
print(f' TLS enabled: cert={TLS_CERT}, key={TLS_KEY}', flush=True)
except Exception as e:
print(f'[!!] WARNING: TLS setup failed ({e}), falling back to HTTP', flush=True)
scheme = 'http'
print(f' Hermes Web UI listening on {scheme}://{HOST}:{PORT}', flush=True)
if HOST == '127.0.0.1':
print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
print(f' Then open: http://localhost:{PORT}', flush=True)
print(f' Then open: {scheme}://localhost:{PORT}', flush=True)
print('', flush=True)
httpd.serve_forever()

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 ───────────────────────────────────────────────────

563
static/i18n.js Normal file
View File

@@ -0,0 +1,563 @@
// ── 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',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Tasks',
tab_skills: 'Skills',
tab_memory: 'Memory',
tab_workspaces: 'Spaces',
tab_profiles: 'Profiles',
tab_todos: 'Todos',
new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...',
scheduled_jobs: 'Scheduled jobs',
new_job: 'New job',
loading: 'Loading...',
search_skills: 'Search skills...',
new_skill: 'New skill',
personal_memory: 'Personal memory',
current_task_list: 'Current task list',
workspace_desc: 'Add and switch workspaces for your sessions.',
new_profile: 'New profile',
transcript: 'Transcript',
download_transcript: 'Download as Markdown',
import: 'Import',
// Settings detail
settings_label_sound: 'Notification sound',
settings_desc_sound: 'Play a sound when the assistant finishes a response.',
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.',
settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.',
password_placeholder: 'Enter new password…',
disable_auth: 'Disable Auth',
sign_out: 'Sign Out',
cancel: 'Cancel',
create_job: 'Create job',
save_skill: 'Save skill',
editing: 'Editing',
// Empty state
empty_title: 'What can I help with?',
empty_subtitle: 'Ask anything, run commands, explore files, or manage your scheduled tasks.',
suggest_files: 'What files are in this workspace?',
suggest_schedule: "What's on my schedule today?",
suggest_plan: 'Help me plan a small project.',
},
de: {
_lang: 'de',
_label: 'Deutsch',
_speech: 'de-DE',
// boot.js
cancelling: 'Wird abgebrochen\u2026',
cancel_failed: 'Abbrechen fehlgeschlagen: ',
mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.',
mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.',
mic_network: 'Spracherkennung nicht verfügbar.',
mic_error: 'Spracheingabefehler: ',
session_imported: 'Sitzung importiert',
import_failed: 'Import fehlgeschlagen: ',
import_invalid_json: 'Ungültiges JSON',
image_pasted: 'Bild eingefügt: ',
// messages.js
edit_message: 'Nachricht bearbeiten',
regenerate: 'Antwort regenerieren',
copy: 'Kopieren',
copied: 'Kopiert!',
you: 'Du',
thinking: 'Nachdenken',
expand_all: 'Alle ausklappen',
collapse_all: 'Alle einklappen',
edit_failed: 'Bearbeiten fehlgeschlagen: ',
regen_failed: 'Regeneration fehlgeschlagen: ',
reconnect_active: 'Eine Antwort wird noch generiert. Neu laden, wenn bereit?',
reconnect_finished: 'Eine Antwort war in Arbeit, als Sie zuletzt gegangen sind. Nachrichten könnten aktualisiert worden sein.',
// approval card
approval_heading: 'Genehmigung erforderlich',
approval_desc_prefix: 'Gefährlicher Befehl erkannt',
approval_btn_once: 'Einmal zulassen',
approval_btn_once_title: 'Diesen einen Befehl zulassen (Enter)',
approval_btn_session: 'Sitzung zulassen',
approval_btn_session_title: 'Für diese Konversationssitzung zulassen',
approval_btn_always: 'Immer zulassen',
approval_btn_always_title: 'Dieses Befehlsmuster immer zulassen',
approval_btn_deny: 'Ablehnen',
approval_btn_deny_title: 'Ablehnen \u2014 diesen Befehl nicht ausführen',
approval_responding: 'Antwortet\u2026',
untitled: 'Unbenannt',
n_messages: (n) => `${n} Nachrichten`,
model_unavailable: ' (nicht verfügbar)',
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
cmd_clear: 'Konversationsverlauf löschen',
cmd_compact: 'Kontext komprimieren',
cmd_model: 'Modell wechseln (z.B. /model gpt-4o)',
cmd_workspace: 'Workspace nach Namen wechseln',
cmd_new: 'Neue Chat-Sitzung starten',
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
available_commands: 'Verfügbare Befehle:',
type_slash: 'Tippe / für Befehle',
conversation_cleared: 'Konversation gelöscht',
model_usage: 'Nutzung: /model <name>',
no_model_match: 'Kein Modell gefunden für "',
switched_to: 'Gewechselt zu ',
workspace_usage: 'Nutzung: /workspace <name>',
no_workspace_match: 'Kein Workspace gefunden für "',
switched_workspace: 'Gewechselt zu Workspace: ',
workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ',
new_session: 'Neue Sitzung erstellt',
compressing: 'Kontext-Komprimierung wird angefordert...',
token_usage_on: 'Token-Verbrauch an',
token_usage_off: 'Token-Verbrauch aus',
theme_usage: 'Nutzung: /theme ',
theme_set: 'Theme: ',
no_active_session: 'Keine aktive Sitzung',
no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)',
available_personalities: 'Verfügbare Persönlichkeiten:',
personality_switch_hint: '\n\nNutze `/personality <name>` zum Wechseln, oder `/personality none` zum Löschen.',
personalities_load_failed: 'Fehler beim Laden der Persönlichkeiten',
personality_cleared: 'Persönlichkeit gelöscht',
personality_set: 'Persönlichkeit: ',
failed_colon: 'Fehlgeschlagen: ',
// ui.js
no_workspace: 'Kein Workspace',
// workspace.js
unsaved_confirm: 'Sie haben ungespeicherte Änderungen in der Vorschau. Verwerfen und fortfahren?',
save: 'Speichern',
edit: 'Bearbeiten',
save_title: 'Änderungen speichern',
edit_title: 'Diese Datei bearbeiten',
saved: 'Gespeichert',
save_failed: 'Speichern fehlgeschlagen: ',
image_load_failed: 'Bild konnte nicht geladen werden',
file_open_failed: 'Datei konnte nicht geöffnet werden',
downloading: (name) => `Lade ${name} herunter\u2026`,
double_click_rename: 'Doppelklick zum Umbenennen',
renamed_to: 'Umbenannt in ',
rename_failed: 'Umbenennen fehlgeschlagen: ',
delete_title: 'Löschen',
delete_confirm: (name) => `${name} löschen?`,
deleted: 'Gelöscht ',
delete_failed: 'Löschen fehlgeschlagen: ',
new_file_prompt: 'Neuer Dateiname (z.B. notes.md):',
created: 'Erstellt ',
create_failed: 'Erstellen fehlgeschlagen: ',
new_folder_prompt: 'Neuer Ordnername:',
folder_created: 'Ordner erstellt ',
folder_create_failed: 'Ordner erstellen fehlgeschlagen: ',
remove_title: 'Entfernen',
empty_dir: '(leer)',
upload_failed: 'Upload fehlgeschlagen: ',
all_uploads_failed: (n) => `Alle ${n} Upload(s) fehlgeschlagen`,
// settings panel
settings_title: 'Einstellungen',
settings_save_btn: 'Einstellungen speichern',
settings_label_model: 'Standard-Modell',
settings_label_send_key: 'Sende-Taste',
settings_label_theme: 'Theme',
settings_label_language: 'Sprache',
settings_label_token_usage: 'Token-Verbrauch anzeigen',
settings_label_cli_sessions: 'CLI-Sitzungen anzeigen',
settings_label_sync_insights: 'Mit Insights synchronisieren',
settings_label_check_updates: 'Nach Updates suchen',
settings_label_bot_name: 'Assistenten-Name',
settings_label_password: 'Zugangspasswort',
settings_saved: 'Einstellungen gespeichert',
settings_save_failed: 'Speichern fehlgeschlagen: ',
settings_load_failed: 'Laden der Einstellungen fehlgeschlagen: ',
settings_saved_pw: 'Einstellungen gespeichert (Passwort gesetzt \u2014 Login jetzt erforderlich)',
// login page
login_title: 'Anmelden',
login_subtitle: 'Geben Sie Ihr Passwort ein, um fortzufahren',
login_placeholder: 'Passwort',
login_btn: 'Anmelden',
login_invalid_pw: 'Ungültiges Passwort',
login_conn_failed: 'Verbindung fehlgeschlagen',
// Sidebar & Tabs
tab_chat: 'Chat',
tab_tasks: 'Aufgaben',
tab_skills: 'Skills',
tab_memory: 'Gedächtnis',
tab_workspaces: 'Spaces',
tab_profiles: 'Profile',
tab_todos: 'Todos',
new_conversation: 'Neuer Chat',
filter_conversations: 'Chats filtern...',
scheduled_jobs: 'Geplante Aufgaben',
new_job: 'Neuer Job',
loading: 'Lädt...',
search_skills: 'Skills suchen...',
new_skill: 'Neuer Skill',
personal_memory: 'Persönliches Gedächtnis',
current_task_list: 'Aktuelle Aufgabenliste',
workspace_desc: 'Workspaces hinzufügen und wechseln.',
new_profile: 'Neues Profil',
transcript: 'Protokoll',
download_transcript: 'Als Markdown herunterladen',
import: 'Importieren',
// Settings detail
settings_label_sound: 'Benachrichtigungston',
settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.',
settings_label_notifications: 'Browser-Benachrichtigungen',
settings_desc_notifications: 'Zeigt eine Systembenachrichtigung an, wenn eine Antwort fertiggestellt wird, während der Tab im Hintergrund ist.',
settings_desc_token_usage: 'Zeigt die Anzahl der Input/Output-Token unter jeder Antwort des Assistenten an. Auch umschaltbar mit /usage.',
settings_desc_cli_sessions: 'Fügt Sitzungen aus der Hermes CLI (state.db) in die Sitzungsliste ein. Klicken Sie auf eine CLI-Sitzung, um sie zu importieren und das Gespräch fortzusetzen.',
settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.',
settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.',
settings_desc_bot_name: 'Anzeigename für den Assistenten in der UI. Standardmäßig Hermes.',
settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.',
password_placeholder: 'Neues Passwort eingeben…',
disable_auth: 'Authentifizierung deaktivieren',
sign_out: 'Abmelden',
cancel: 'Abbrechen',
create_job: 'Job erstellen',
save_skill: 'Skill speichern',
editing: 'Bearbeitung',
// Empty state
empty_title: 'Wie kann ich helfen?',
empty_subtitle: 'Frage mich alles, führe Befehle aus, erkunde Dateien oder verwalte deine Aufgaben.',
suggest_files: 'Welche Dateien sind in diesem Workspace?',
suggest_schedule: 'Was steht heute auf meinem Plan?',
suggest_plan: 'Hilf mir, ein kleines Projekt zu planen.',
},
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;
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
const val = t(key);
if (val && val !== key) el.title = val;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const val = t(key);
if (val && val !== key) el.placeholder = val;
});
}
// Apply saved locale immediately so there's no flash of English on reload.
loadLocale();

View File

@@ -14,32 +14,32 @@
<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.43.0</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>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">&#129513;</button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">&#129504;</button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">&#128193;</button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">&#9989;</button>
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks">&#128197;</button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills">&#129513;</button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory">&#129504;</button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces">&#128193;</button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos">&#9989;</button>
</div>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="sidebar-section">
<button class="new-chat-btn" id="btnNewChat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New conversation <span style="font-size:10px;opacity:.5;margin-left:4px">&#8984;K</span>
<span data-i18n="new_conversation">New conversation</span> <span style="font-size:10px;opacity:.5;margin-left:4px">&#8984;K</span>
</button>
</div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." oninput="filterSessions()"></div>
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()"></div>
<div class="session-list" id="sessionList"></div>
</div>
<!-- Tasks (cron) panel -->
<div class="panel-view" id="panelTasks">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ New job</button>
<div style="font-size:11px;color:var(--muted)" data-i18n="scheduled_jobs">Scheduled jobs</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ <span data-i18n="new_job">New job</span></button>
</div>
<!-- Create job form (hidden by default) -->
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
@@ -57,18 +57,18 @@
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
</div>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()" data-i18n="create_job">Create job</button>
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ New skill</button>
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ <span data-i18n="new_skill">New skill</span></button>
</div>
<!-- Skill create/edit form (hidden by default) -->
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
@@ -76,46 +76,46 @@
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()" data-i18n="save_skill">Save skill</button>
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()" data-i18n="cancel">Cancel</button>
</div>
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Memory panel -->
<div class="panel-view" id="panelMemory">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<span style="font-size:11px;color:var(--muted)">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">&#9998; Edit</button>
<span style="font-size:11px;color:var(--muted)" data-i18n="personal_memory">Personal memory</span>
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">&#9998; <span data-i18n="edit">Edit</span></button>
</div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
<!-- Memory edit form (hidden by default) -->
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Editing: <span id="memEditSection">memory</span></div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px"><span data-i18n="editing">Editing</span>: <span id="memEditSection">memory</span></div>
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
<div style="display:flex;gap:6px">
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()">Cancel</button>
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()" data-i18n="save">Save</button>
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()" data-i18n="cancel">Cancel</button>
</div>
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
</div>
</div>
<!-- Todo panel -->
<div class="panel-view" id="panelTodos">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0">Current task list</div>
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Profiles panel -->
<div class="panel-view" id="panelProfiles">
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:11px;color:var(--muted)">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ New profile</button>
<div style="font-size:11px;color:var(--muted)" data-i18n="tab_profiles">Agent profiles</div>
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleProfileForm()">+ <span data-i18n="new_profile">New profile</span></button>
</div>
<!-- Profile create form (hidden by default) -->
<div id="profileCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
@@ -163,9 +163,9 @@
<div class="ws-dropdown" id="wsDropdown"></div>
</div>
<div class="sidebar-actions">
<button class="sm-btn" id="btnDownload" title="Download as Markdown">&#8595; Transcript</button>
<button class="sm-btn" id="btnDownload" title="Download as Markdown" data-i18n-title="download_transcript">&#8595; <span data-i18n="transcript">Transcript</span></button>
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">&#10092;/&#10093; JSON</button>
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON">&#8593; Import</button>
<button class="sm-btn" id="btnImportJSON" title="Import session from JSON">&#8593; <span data-i18n="import">Import</span></button>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
</div>
@@ -176,7 +176,7 @@
<button class="mobile-hamburger" id="btnHamburger" onclick="toggleMobileSidebar()" title="Menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
<div class="topbar-chips">
<div id="profileChipWrap" style="position:relative">
<div class="chip profile-chip" id="profileChip" onclick="toggleProfileDropdown()" title="Switch profile" style="cursor:pointer"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span id="profileChipLabel">default</span> &#9662;</div>
@@ -184,20 +184,20 @@
</div>
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; Clear</button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings">&#9881;</button>
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">&#128465; <span data-i18n="copy">Clear</span></button>
<button class="chip gear-btn" id="btnSettings" onclick="toggleSettings()" title="Settings" data-i18n-title="settings_title">&#9881;</button>
<button class="chip mobile-files-btn" id="btnMobileFiles" onclick="toggleMobileFiles()" title="Files">&#128193;</button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state" id="emptyState">
<div class="empty-logo">🦉</div>
<h2>What can I help with?</h2>
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<h2 data-i18n="empty_title">What can I help with?</h2>
<p data-i18n="empty_subtitle">Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion" data-msg="What files are in this workspace?">📁 What files are in this workspace?</button>
<button class="suggestion" data-msg="What's on my schedule today?">📋 What's on my schedule today?</button>
<button class="suggestion" data-msg="Help me plan a small project.">🗺 Help me plan a small project.</button>
<button class="suggestion" data-msg="What files are in this workspace?">📁 <span data-i18n="suggest_files">What files are in this workspace?</span></button>
<button class="suggestion" data-msg="What's on my schedule today?">📋 <span data-i18n="suggest_schedule">What's on my schedule today?</span></button>
<button class="suggestion" data-msg="Help me plan a small project.">🗺 <span data-i18n="suggest_plan">Help me plan a small project.</span></button>
</div>
</div>
<div class="messages-inner" id="msgInner"></div>
@@ -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,44 +355,68 @@
<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)">
<span data-i18n="settings_label_sound">Notification sound</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sound">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)">
<span data-i18n="settings_label_notifications">Browser notifications</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">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 style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">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 style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">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 style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sync_insights">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 style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">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" data-i18n="settings_desc_bot_name">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>
<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">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">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…" data-i18n-placeholder="password_placeholder" 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" 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>
<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" data-i18n="disable_auth">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" data-i18n="sign_out">Sign Out</button>
</div>
</div>
</div>
@@ -387,26 +424,27 @@
<nav class="mobile-bottom-nav" id="mobileBottomNav">
<button class="mobile-nav-btn active" data-panel="chat" onclick="mobileSwitchPanel('chat')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>Chat</span>
<span data-i18n="tab_chat">Chat</span>
</button>
<button class="mobile-nav-btn" data-panel="tasks" onclick="mobileSwitchPanel('tasks')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Tasks</span>
<span data-i18n="tab_tasks">Tasks</span>
</button>
<button class="mobile-nav-btn" data-panel="skills" onclick="mobileSwitchPanel('skills')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span>Skills</span>
<span data-i18n="tab_skills">Skills</span>
</button>
<button class="mobile-nav-btn" data-panel="memory" onclick="mobileSwitchPanel('memory')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg>
<span>Memory</span>
<span data-i18n="tab_memory">Memory</span>
</button>
<button class="mobile-nav-btn" data-panel="workspaces" onclick="mobileSwitchPanel('workspaces')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h8l2 2h10v14H2z"/></svg>
<span>Spaces</span>
<span data-i18n="tab_workspaces">Spaces</span>
</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

@@ -210,6 +210,18 @@ def test_server():
Start an isolated test server on TEST_PORT with a clean state directory.
Paths are discovered dynamically -- no hardcoded absolute path assumptions.
"""
# Kill any leftover process on the test port before starting.
# Stale servers from QA harness runs or prior test sessions cause
# conftest to think the server is already up, producing false failures.
try:
import subprocess as _sp
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
capture_output=True, timeout=5)
except Exception:
pass
import time as _time
_time.sleep(0.5) # brief pause to let the port release
# Clean slate
if TEST_STATE_DIR.exists():
shutil.rmtree(TEST_STATE_DIR)

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

134
tests/test_auth_sessions.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Tests for auth session lifecycle — session creation, verification, expiry,
and lazy pruning of expired entries.
"""
import time
import unittest
from pathlib import Path
import tempfile
import os
# Isolate state dir so we don't touch real sessions
_TEST_STATE = Path(tempfile.mkdtemp())
os.environ["HERMES_WEBUI_STATE_DIR"] = str(_TEST_STATE)
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
import importlib
# Force re-import of auth module so it picks up our TEST_STATE_DIR
auth = importlib.import_module("api.auth")
class TestSessionPruning(unittest.TestCase):
"""Verify expired session cleanup works correctly."""
def setUp(self):
# Clear any leftover sessions from other tests
auth._sessions.clear()
def test_session_created_valid(self):
"""A fresh session token should verify as valid."""
token = auth.create_session()
self.assertTrue(auth.verify_session(token))
def test_expired_session_pruned(self):
"""Manually inserting an expired entry should be pruned on next verify_session call."""
# Insert sessions that have already expired
auth._sessions["fake_token"] = time.time() - 100
auth._sessions["another_fake"] = time.time() - 50
# Insert one valid session (far future)
auth._sessions["good_token"] = time.time() + 3600
# _sessions has 3 entries, 2 expired
self.assertEqual(len(auth._sessions), 3)
# Call verify_session — this triggers _prune_expired_sessions()
# Cookie format is token.signature, so we need a dot to pass the early check
auth.verify_session("fake_token.fake_sig")
# After verification, only the valid session should remain
self.assertEqual(len(auth._sessions), 1)
self.assertIn("good_token", auth._sessions)
self.assertNotIn("fake_token", auth._sessions)
self.assertNotIn("another_fake", auth._sessions)
def test_prune_does_not_remove_valid_sessions(self):
"""_prune_expired_sessions should never remove sessions that are still active."""
auth._sessions["active_1"] = time.time() + 86400 # 24 hours from now
auth._sessions["active_2"] = time.time() + 7200 # 2 hours from now
auth._sessions["expired_1"] = time.time() - 10
auth._prune_expired_sessions()
self.assertEqual(len(auth._sessions), 2)
self.assertIn("active_1", auth._sessions)
self.assertIn("active_2", auth._sessions)
self.assertNotIn("expired_1", auth._sessions)
def test_verify_session_prunes_before_verification(self):
"""verify_session should prune expired entries before checking the target token.
This ensures that _prune_expired_sessions() is called at the very top
of verify_session(), so cleanup happens on every auth check.
"""
auth._sessions["expired_for_test"] = time.time() - 999
# verify_session with an invalid cookie triggers the full path:
# _prune_expired_sessions -> signature check -> return False
result = auth.verify_session("nonexistent.bad_sig")
self.assertFalse(result)
# The expired entry should have been cleaned up
self.assertNotIn("expired_for_test", auth._sessions)
def test_prune_handles_empty_dict(self):
"""_prune_expired_sessions should be safe on an empty dict."""
auth._sessions.clear()
auth._prune_expired_sessions()
self.assertEqual(len(auth._sessions), 0)
def test_session_ttl_is_24_hours(self):
"""Newly created sessions should have the expected 24-hour TTL."""
auth._sessions.clear()
token_hex = auth.create_session().split(".")[0]
# The _sessions dict stores token -> expiry_time
# We can check the expiry is approximately SESSION_TTL seconds from now
# by looking up the raw entry via the token
from api.auth import _sessions, SESSION_TTL
# find our entry
for t, exp in _sessions.items():
if t == token_hex:
# expiry should be within 5 seconds of now + SESSION_TTL
expected = time.time() + SESSION_TTL
self.assertAlmostEqual(exp, expected, delta=5)
break
else:
self.fail("Session token not found in _sessions")
class TestSessionInvalidation(unittest.TestCase):
"""Test session logout / invalidation."""
def setUp(self):
auth._sessions.clear()
def test_invalidate_session_removes_token(self):
"""Calling invalidate_session should remove the token from _sessions."""
token = auth.create_session()
self.assertTrue(auth.verify_session(token))
auth.invalidate_session(token)
# Token should be gone
self.assertFalse(auth.verify_session(token))
def test_invalidate_unknown_token_is_safe(self):
"""Invalidating a non-existent token should not raise."""
auth._sessions.clear()
auth.invalidate_session("nonexistent_token")
# Should not raise
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,328 @@
"""
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, custom_providers=None):
"""Helper: temporarily set config.cfg model/custom provider sections, 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 {}
if custom_providers is not None:
config.cfg['custom_providers'] = custom_providers
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'
def test_custom_provider_model_with_slash_routes_to_named_custom_provider():
"""Slash-containing custom endpoint model IDs must not be mistaken for OpenRouter models."""
model, provider, base_url = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='openrouter',
base_url='https://openrouter.ai/api/v1',
custom_providers=[{
'name': 'Local LM Studio',
'base_url': 'http://lmstudio.local:1234/v1',
'model': 'google/gemma-4-26b-a4b',
}],
)
assert model == 'google/gemma-4-26b-a4b'
assert provider == 'custom:local-lm-studio'
assert base_url == 'http://lmstudio.local:1234/v1'
# ── 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"
)
# ── get_available_models(): phantom "Custom" group regression ─────────────
#
# When the user has model.provider set to a real provider (e.g. openai-codex)
# AND a model.base_url set, hermes_cli reports the 'custom' pseudo-provider as
# authenticated. The WebUI picker must NOT build a separate "Custom" group in
# that case — the base_url belongs to the active provider.
def _available_models_with_full_cfg(provider, default, base_url):
"""Helper: set model.provider, model.default, model.base_url at once.
Clears model-override env vars (HERMES_MODEL, OPENAI_MODEL, LLM_MODEL)
during the call so the real hermes profile environment doesn't leak into
the test and override the fixture's default model.
"""
import os
import api.config as _cfg
old_cfg = dict(_cfg.cfg)
_cfg.cfg['model'] = {
'provider': provider,
'default': default,
'base_url': base_url,
}
# Clear model-override env vars to prevent the real profile from leaking in
_model_env_keys = ('HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL')
_saved_env = {k: os.environ.pop(k, None) for k in _model_env_keys}
try:
return _cfg.get_available_models()
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
for k, v in _saved_env.items():
if v is not None:
os.environ[k] = v
def test_no_phantom_custom_group_when_active_provider_is_set(monkeypatch):
"""Issue: with provider=openai-codex + base_url set, gpt-5.4 was landing
under a phantom "Custom" group instead of the "OpenAI Codex" group."""
import sys, types
# Force hermes_cli to report both the real provider and the phantom
# 'custom' as authenticated, simulating what list_available_providers()
# returns when base_url is configured.
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'openai-codex', 'authenticated': True},
{'id': 'custom', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4',
base_url='https://chatgpt.com/backend-api/codex',
)
group_names = [g['provider'] for g in result['groups']]
assert 'Custom' not in group_names, (
f"Phantom 'Custom' group present; full groups: {group_names}"
)
def test_default_model_lands_under_active_provider_group(monkeypatch):
"""The configured default_model must appear under the active provider's
display group, even when the model isn't in _PROVIDER_MODELS[provider]
AND the active provider isn't the alphabetical first detected provider.
Regression guard for a hyphen-vs-space bug in the "ensure default_model
appears" post-pass: the substring check `active_provider.lower() in
g.get('provider', '').lower()` was failing for 'openai-codex' vs
display name 'OpenAI Codex' (hyphen vs. space), silently falling back
to groups[0] — which, when another provider sorted earlier
alphabetically (e.g. 'anthropic'), placed gpt-5.4 in the WRONG group.
"""
import sys, types
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'anthropic', 'authenticated': True}, # sorts before openai-codex
{'id': 'openai-codex', 'authenticated': True},
{'id': 'custom', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4',
base_url='https://chatgpt.com/backend-api/codex',
)
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
assert 'OpenAI Codex' in groups, f"OpenAI Codex group missing: {list(groups)}"
assert 'gpt-5.4' in groups['OpenAI Codex'], (
f"gpt-5.4 not in OpenAI Codex group; contents: {groups['OpenAI Codex']}"
)
# And crucially, it must NOT have landed in the alphabetically-first
# group (Anthropic) via the fallback path.
assert 'gpt-5.4' not in groups.get('Anthropic', []), (
f"gpt-5.4 leaked into Anthropic group via fallback: {groups.get('Anthropic')}"
)

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

466
tests/test_sprint29.py Normal file
View File

@@ -0,0 +1,466 @@
"""
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_new_format_session_id_passes_validation(self):
"""New hermes-agent session IDs (YYYYMMDD_HHMMSS_xxxxxx) must pass validation."""
from api.models import Session
# Should pass the validator (returns None only because the file doesn't exist)
result = Session.load("20260406_164014_74b2d1")
assert result is None # file doesn't exist, but validator passed
def test_non_hex_session_id_rejected(self):
"""A session ID with dangerous chars must be rejected."""
from api.models import Session
evil_ids = [
"../../../etc/passwd",
"../../../../root/.ssh/id_rsa",
"session; rm -rf /",
"hello world",
"ZZZZZZZZZZZZZZZZ",
"session\x00evil",
"..\\..\\windows\\system32",
"session/../../etc/passwd",
"valid_looking.json",
]
for sid in evil_ids:
result = Session.load(sid)
assert result is None, \
f"Session.load should reject dangerous 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"

71
tests/test_sprint32.py Normal file
View File

@@ -0,0 +1,71 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
import subprocess
from api.startup import auto_install_agent_deps
class TestAutoInstallAgentDeps:
def test_installs_from_requirements_txt(self, tmp_path):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
req = agent_dir / 'requirements.txt'
req.write_text('pyyaml\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
assert auto_install_agent_deps() is True
args = mock_run.call_args[0][0]
assert '-r' in args and str(req) in args
def test_falls_back_to_pyproject(self, tmp_path):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
(agent_dir / 'pyproject.toml').write_text('[project]\nname="hermes-agent"\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=0, stderr='')
assert auto_install_agent_deps() is True
args = mock_run.call_args[0][0]
assert str(agent_dir) in args and '-r' not in args
def test_skips_when_agent_dir_missing(self, tmp_path, capsys):
missing = tmp_path / 'nonexistent-agent'
# Patch both HERMES_WEBUI_AGENT_DIR and HERMES_HOME so the fallback
# path (HERMES_HOME/hermes-agent) also resolves to a nonexistent dir,
# preventing the real agent dir from being found in the test environment.
env_overrides = {
'HERMES_WEBUI_AGENT_DIR': str(missing),
'HERMES_HOME': str(tmp_path / 'no-hermes-home'),
}
with patch.dict('os.environ', env_overrides, clear=False):
with patch('subprocess.run') as mock_run:
assert auto_install_agent_deps() is False
assert not mock_run.called
assert 'skipped' in capsys.readouterr().out.lower()
def test_skips_when_no_install_file(self, tmp_path, capsys):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
assert auto_install_agent_deps() is False
assert not mock_run.called
assert 'skipped' in capsys.readouterr().out.lower()
def test_tolerates_pip_failure(self, tmp_path, capsys):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
(agent_dir / 'requirements.txt').write_text('somepkg\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run') as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr='ERROR: could not find package')
assert auto_install_agent_deps() is False
assert 'failed' in capsys.readouterr().out.lower() or 'pip' in capsys.readouterr().out.lower()
def test_tolerates_timeout(self, tmp_path, capsys):
agent_dir = tmp_path / 'hermes-agent'
agent_dir.mkdir()
(agent_dir / 'requirements.txt').write_text('somepkg\n')
with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False):
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('pip', 120)):
assert auto_install_agent_deps() is False
assert 'timed out' in capsys.readouterr().out.lower()

214
tests/test_tls_support.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Tests for optional TLS/HTTPS support (HERMES_WEBUI_TLS_CERT / TLS_KEY).
Tests use a self-signed certificate generated at test time via openssl.
"""
import http.client
import json
import os
import ssl
import subprocess
import textwrap
import time
import tempfile
import unittest
from contextlib import suppress
from pathlib import Path
ROOT = Path(__file__).parent.parent
def _gen_test_cert(tmpdir: Path) -> tuple[str, str]:
"""Generate a self-signed cert and key pair for testing."""
cert = str(tmpdir / "test_cert.pem")
key = str(tmpdir / "test_key.pem")
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", key, "-out", cert, "-days", "1", "-nodes",
"-subj", "/CN=localhost"],
check=True, capture_output=True,
)
return cert, key
def _find_free_port() -> int:
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_for_server(host: str, port: int, use_ssl: bool = False,
timeout: float = 8.0) -> bool:
"""Poll until the server accepts a connection or times out."""
ctx = None
if use_ssl:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
deadline = time.time() + timeout
while time.time() < deadline:
try:
if use_ssl:
c = http.client.HTTPSConnection(host, port, timeout=2, context=ctx)
else:
c = http.client.HTTPConnection(host, port, timeout=2)
c.request("GET", "/health")
resp = c.getresponse()
resp.read()
c.close()
return True
except Exception:
time.sleep(0.5)
return False
def _start_server(port: int, cert: str = None, key: str = None) -> subprocess.Popen:
"""Start server.py as a subprocess with the given TLS env vars."""
env = {k: v for k, v in os.environ.items()}
env["HERMES_WEBUI_HOST"] = "127.0.0.1"
env["HERMES_WEBUI_PORT"] = str(port)
env.pop("HERMES_WEBUI_TLS_CERT", None)
env.pop("HERMES_WEBUI_TLS_KEY", None)
if cert:
env["HERMES_WEBUI_TLS_CERT"] = cert
if key:
env["HERMES_WEBUI_TLS_KEY"] = key
env["HERMES_WEBUI_STATE_DIR"] = str(Path(tempfile.mkdtemp()))
proc = subprocess.Popen(
[os.sys.executable, str(ROOT / "server.py")],
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True,
)
return proc
# ── Test class ──────────────────────────────────────────────────────────────
class TestTLSConfigFlag(unittest.TestCase):
def test_tls_enabled_true_when_both_env_set(self):
code = textwrap.dedent("""\
import os
os.environ['HERMES_WEBUI_TLS_CERT'] = '/tmp/cert.pem'
os.environ['HERMES_WEBUI_TLS_KEY'] = '/tmp/key.pem'
from api.config import TLS_ENABLED
print(TLS_ENABLED)
""")
r = subprocess.run(
[os.sys.executable, "-c", code],
capture_output=True, text=True, timeout=10,
cwd=str(ROOT),
)
self.assertEqual(r.stdout.strip(), "True")
def test_tls_enabled_false_when_env_absent(self):
env = {k: v for k, v in os.environ.items()
if k not in ("HERMES_WEBUI_TLS_CERT", "HERMES_WEBUI_TLS_KEY")}
code = textwrap.dedent("""\
import os
os.environ.pop('HERMES_WEBUI_TLS_CERT', None)
os.environ.pop('HERMES_WEBUI_TLS_KEY', None)
from api.config import TLS_ENABLED
print(TLS_ENABLED)
""")
r = subprocess.run(
[os.sys.executable, "-c", code],
capture_output=True, text=True, timeout=10,
cwd=str(ROOT), env=env,
)
self.assertEqual(r.stdout.strip(), "False")
def test_tls_enabled_false_when_only_cert_set(self):
env = {k: v for k, v in os.environ.items()
if k not in ("HERMES_WEBUI_TLS_CERT", "HERMES_WEBUI_TLS_KEY")}
env["HERMES_WEBUI_TLS_CERT"] = "/tmp/cert.pem"
code = textwrap.dedent("""\
from api.config import TLS_ENABLED
print(TLS_ENABLED)
""")
r = subprocess.run(
[os.sys.executable, "-c", code],
capture_output=True, text=True, timeout=10,
cwd=str(ROOT), env=env,
)
self.assertEqual(r.stdout.strip(), "False")
class TestTLSEndToEnd(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._tmpdir = Path(tempfile.mkdtemp())
cls._cert, cls._key = _gen_test_cert(cls._tmpdir)
@classmethod
def tearDownClass(cls):
with suppress(Exception):
import shutil
shutil.rmtree(cls._tmpdir, ignore_errors=True)
def tearDown(self):
if hasattr(self, "_proc") and self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
def test_https_server_responds_to_health(self):
port = _find_free_port()
self._proc = _start_server(port, cert=self._cert, key=self._key)
self.assertTrue(
_wait_for_server("127.0.0.1", port, use_ssl=True),
"TLS server did not start in time",
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection("127.0.0.1", port, timeout=5, context=ctx)
conn.request("GET", "/health")
resp = conn.getresponse()
self.assertEqual(resp.status, 200)
data = json.loads(resp.read())
self.assertEqual(data.get("status"), "ok")
conn.close()
def test_http_without_tls_still_works(self):
port = _find_free_port()
self._proc = _start_server(port)
self.assertTrue(
_wait_for_server("127.0.0.1", port, use_ssl=False),
)
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.request("GET", "/health")
resp = conn.getresponse()
self.assertEqual(resp.status, 200)
data = json.loads(resp.read())
self.assertEqual(data.get("status"), "ok")
conn.close()
def test_tls_startup_failure_fallback_to_http(self):
"""Bad cert paths should print a warning and start HTTP anyway."""
port = _find_free_port()
self._proc = _start_server(
port, cert="/nonexistent/cert.pem", key="/nonexistent/key.pem",
)
# Server should be reachable over plain HTTP even though TLS setup failed
self.assertTrue(
_wait_for_server("127.0.0.1", port, use_ssl=False),
"HTTP fallback server did not start after TLS failure",
)
# Confirm TLS warning was printed
import fcntl
os.set_blocking(self._proc.stdout.fileno(), False)
output = ""
try:
output = self._proc.stdout.read(2000) or ""
except BlockingIOError:
output = ""
self.assertIn("TLS setup failed", output)
if __name__ == "__main__":
unittest.main()