BUG-1 (critical): api/profiles.py _DEFAULT_HERMES_HOME used Path.home()/.hermes
hardcoded, ignoring the HERMES_HOME env var. conftest.py sets HERMES_HOME to a
test-isolated state dir -- but profiles.py bypassed it and read/wrote real ~/.hermes
during every test run (active_profile file, .env loading). Fixed by reading
os.getenv('HERMES_HOME', ...) at module load time.
BUG-7 (medium): api/workspace.py load_workspaces() fell back to the global
workspaces.json for ALL profiles when their profile-local file didn't exist yet.
New named profiles silently inherited the default profile's workspace list instead
of starting clean. Fixed: the global file fallback now only applies to the default
profile (migration path); named profiles start with a fresh default entry.
BUG-4 (high): test_sessions_list_includes_profile had a vacuous 'if matching:'
guard -- if the session wasn't found the assert was silently skipped and the test
passed. Fixed with hard assert. Also changed to use /api/session?session_id=
directly instead of scanning /api/sessions (which filters out empty Untitled
sessions with 0 messages, causing the test to always see an empty match list).
BUG-5 / test ordering regression: test_profile_switch_returns_default_model_and_workspace
failed with 409 because test_chat_stream_opens_successfully (runs earlier in the
suite) starts a real LLM stream that stays alive in STREAMS. Added a wait loop
(up to 30s) polling /health active_streams before attempting the profile switch.
BUG-8 (low): Removed dead import _profile_default_workspace in switch_profile()
-- was imported but never used (get_last_workspace() already delegates to it).
Also: test_profile_active_endpoint hardcoded assert data['name'] == 'default'
which fails if a prior run left a non-default active_profile on disk. Changed
to assert name is a non-empty string (the endpoint contract), not a specific value.
Tests: 423 passed, 0 failed.
Fix five coherence bugs in profile switching:
1. Model picker ignored profile default (localStorage stale key)
2. Workspace list was global (not profile-scoped)
3. DEFAULT_WORKSPACE was a boot-time singleton
4. Session list showed all profiles (no filtering)
5. switchToProfile() didn't refresh workspaces or sessions
Backend: workspace storage is now profile-local for named profiles,
switch_profile() returns default_model and default_workspace.
Frontend: switchToProfile() clears stale model pref, refreshes
workspace list and session list, sessions.js filters by active profile
with 'Show N from other profiles' toggle.
8 new tests. 400 pass / 23 fail (identical to baseline).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rfind('@keyframes') searched backward from 'send-pop-in' but with both
keyframes on the same CSS line, it landed on mic-pulse instead.
Fix: use find('@keyframes send-pop-in') directly (forward search) via
a shared _extract_keyframe() helper. Same fix applied to both
test_send_pop_in_uses_scale and test_send_pop_in_uses_opacity.
- index.html: btnSend hidden by default (display:none), icon-only (upward
arrow SVG, no text label), title attribute for accessibility
- style.css: new send-btn design — 34px circle, blue fill (#7cb9ff),
subtle glow box-shadow, scale() hover/active for tactile feel,
.send-btn.visible with @keyframes send-pop-in (scale+opacity spring
using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile
override updated to preserve circle dimensions.
- ui.js: updateSendBtn() — shows button with pop-in animation when
textarea has content OR files are attached and agent is not busy;
hides instantly when content is cleared. Hooked into setBusy() and
renderTray() so button state tracks all content sources correctly.
- boot.js: input event listener calls updateSendBtn() on every keystroke.
- messages.js: autoResize() calls updateSendBtn() so button disappears
immediately after send clears the textarea.
- tests/test_sprint21.py: 33 tests covering HTML structure, CSS design
(circle shape, colors, animations, keyframes), JS logic (updateSendBtn,
setBusy, renderTray, autoResize integration), and regressions
(363 total, all pass).
Previously, tapping the mic button would reset the textarea each time,
clobbering anything the user had already typed or previously dictated.
Fix:
- Capture _prefix = ta.value when recording starts (btn.onclick)
- onresult writes _prefix + (final || interim) so live interim text
appears after the existing content, not replacing it
- onend commits _prefix + _finalText with smart space insertion:
if the prefix doesn't end with a space or newline, a space is added
before the new transcript so words don't run together
- _prefix is reset to '' in _setRecording(false) so each new recording
session starts with a fresh snapshot
Behaviour now: tap mic, speak, tap again (or wait for auto-stop) ->
transcript is appended to whatever was in the textarea. Tap mic again
-> continues appending further. Text stays fully editable before send.
tests/test_sprint20.py: 6 new tests covering prefix capture, onresult
prepend, onend commit, reset, and smart spacing (52 total, 382 overall).
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
Tracebacks exposed file paths, module names, and potentially secret
values from local variables. Now logged server-side only; clients
receive a generic error message.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three security issues found during review:
1. password_hash exposed via GET /api/settings
load_settings() returned all fields including the stored hash.
Fix: strip password_hash from the response in routes.py.
2. password_hash directly settable via POST /api/settings
'password_hash' was in _SETTINGS_ALLOWED_KEYS, so an attacker
could POST {password_hash: 'X'} to hijack auth without knowing
the current password.
Fix: exclude password_hash from _SETTINGS_ALLOWED_KEYS.
(Use _set_password for the legitimate hash-and-store path.)
3. Security headers missing from /api/auth/login and /api/auth/logout
These endpoints built their responses manually (bypassing j()),
so they omitted X-Content-Type-Options etc.
Fix: call _security_headers() before end_headers() on both.
Tests updated: renamed test to assert key absent (not just None),
added new test verifying direct password_hash POST is blocked.
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)
Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS
Closes#23. 9 new tests. Total: 304 passed, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes#26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
Session projects: named groups for organizing sessions. Project filter
bar with chips between search and session list. Create/rename/delete
projects, assign sessions via folder icon dropdown. Stored in
projects.json, project_id on Session model. 5 new API endpoints.
Code block copy button: every code block gets a Copy button in the
language header (or top-right for plain blocks). Clipboard API with
"Copied!" feedback.
Tool card expand/collapse: messages with 2+ tool cards get an
"Expand all / Collapse all" toggle above the card group.
13 new tests (237 total), all passing. No regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>