# Hermes Web UI -- Changelog ## [v0.50.58] — 2026-04-16 ### Fixed - **Custom provider name in model dropdown** — when a `custom_providers` entry in `config.yaml` has a `name` field (e.g. `Agent37`), the model picker now shows that name as the group header instead of the generic `Custom` label. Multiple named providers each get their own group. Unnamed entries still fall back to `Custom`. Brings the web UI into parity with the terminal's provider display. (Fixes #557) ## [v0.50.57] — 2026-04-15 ### Added - **Auto-generated session titles** — after the first exchange, a background thread generates a concise title from the first user message and assistant reply, replacing the default first-message substring. Updates live in the UI via a new `title` SSE event. Manual renames are preserved; generation only runs once per session. Includes MiniMax token budget handling and a local heuristic fallback. (Fixes #495, PR #535 by @franksong2702) ### Changed - **SSE stream termination** — streams now end with `stream_end` instead of `done` so the background title generation thread has time to emit the title update before the client disconnects. ## [v0.50.55] — 2026-04-15 ### Fixed - **Docker honcho extra** — `docker_init.bash` now installs `hermes-agent[honcho]` so `honcho-ai` is included in the venv on every fresh Docker build. Fixes `"Honcho session could not be initialized."` errors on rebuilt containers. (Fixes #553) - **Version badge** — `index.html` version badge corrected to v0.50.55 (was missing the bump for this release). ## [v0.50.54] — 2026-04-15 ### Changed - **OpenRouter model list** — updated to 14 current models across 7 providers. All slugs verified live against the OpenRouter catalog. Removed `o4-mini`, old Gemini 2.x entries, and Llama 4. Added Claude Opus 4.6, GPT-5.4, Gemini 3.1 Pro Preview, Gemini 3 Flash Preview, DeepSeek R1, Qwen3 Coder, Qwen3.6 Plus, Grok 4.20, and Mistral Large. Both Claude 4.6 and 4.5 generations preserved. Fixed `grok-4-20` → `grok-4.20` slug and Gemini `-preview` suffixes. ## [v0.50.53] — 2026-04-15 ### Fixed - **Custom endpoint slash model IDs** — model IDs with vendor prefixes that are intrinsic (e.g. `zai-org/GLM-5.1` on DeepInfra) are now preserved when routing to a custom `base_url` endpoint. Previously, all prefixed IDs were stripped, causing `model_not_found` errors on providers that require the full vendor/model format. Known provider namespaces (`openai/`, `google/`, `anthropic/`, etc.) are still stripped as before. (Fixes #548, PR #549 by @eba8) ## [v0.50.52] — 2026-04-15 ### Fixed - **Simultaneous approval requests** — parallel tool calls that each require approval no longer overwrite each other. `_pending` is now a list per session; each entry gets a stable `approval_id` (uuid4) so `/api/approval/respond` can target a specific request. The UI shows a "1 of N pending" counter when multiple approvals are queued. Backward-compatible with old agent versions and old frontend clients. Adds 14 regression tests. (Fixes #527) ## [v0.50.51] — 2026-04-15 ### Fixed - **Orphaned tool messages** — conversation histories containing `role: tool` messages with no matching `tool_call_id` in a prior assistant message are now silently stripped before sending to the provider API. Fixes 400 errors from strictly-conformant providers (Mercury-2/Inception, newer OpenAI models). Adds 13 regression tests. (Fixes #534) ## [v0.50.50] — 2026-04-15 ### Fixed - **Code block syntax highlighting** — Prism theme now follows the active UI theme. Light mode uses the default Prism light theme; dark mode uses `prism-tomorrow`. Theme swaps happen immediately on toggle including on first load. Adds `id="prism-theme"` to the Prism CSS link so JavaScript can locate and swap it. (Closes #505, PR #530 by @mariosam95) ## [v0.50.49] — 2026-04-15 ### Fixed - **IME composition** — `isComposing` guard added to every Enter keydown handler so CJK/Japanese/Korean input method users never accidentally send mid-composition (fixes #531). Covers chat composer, command dropdown, session rename, project create/rename, app dialog, message edit, and workspace rename. Adds 3 regression tests. (PR #537 by @vansour) ## [v0.50.48] fix: toast when model is switched during active session (#419) Synthesized from PRs #516 (armorbreak001), #517 and #518 (cloudyun888). When a user switches the model via the model picker while a session already has messages, a 3-second toast now reads: "Model change takes effect in your next conversation." This avoids the confusing situation where the dropdown shows the new model but the current conversation continues with the original one. The toast fires from `modelSelect.onchange` in `static/boot.js`, after the existing provider-mismatch warning. It checks `S.messages.length > 0` (the reliable in-memory array, always initialized by `loadSession`). The `showToast` call is guarded with `typeof` for safety during boot. Key differences from submitted PRs: placement in boot.js onchange (covers all selection paths including chip dropdown, since `selectModelFromDropdown` calls `sel.onchange`), and uses `S.messages` not `S.session.messages`. 4 new tests in `tests/test_provider_mismatch.py::TestModelSwitchToast`. Total tests: 1272 (was 1268) ## [v0.50.47] fix/feat: batch fixes — root workspace, custom providers, cron cache, system theme Synthesized from PRs #506, #507, #508, #509, #510, #514, #515, #519, #521. ### Fixes **Allow /root as a workspace path** (PRs #510, #521 by @ccqqlo) Removes `/root` from `_BLOCKED_SYSTEM_ROOTS` in `api/workspace.py`, so deployments running as root (Docker, VPS) can set `/root` as their workspace without a "system directory" rejection. **Guard against split on missing [Attached files:]** (PR #521 by @ccqqlo) `base_text` extraction in `api/streaming.py` now guards: `msg_text.split(...)[0] if ... in msg_text else msg_text`. Previously split on the empty case returned an empty string, causing attachment-matching to silently fail on messages with no attachments. **custom_providers models visible regardless of active provider** (#515, #519 by @shruggr, @cloudyun888) `get_available_models()` in `api/config.py` no longer discards the 'custom' provider from `detected_providers` when the user has `custom_providers` entries in `config.yaml`. Previously, switching active_provider away from 'custom' hid all custom model definitions from the picker. **Cron skill picker cache invalidated on form open and skill save** (PRs #507, #508 by @armorbreak001) `toggleCronForm()` now unconditionally nulls `_cronSkillsCache` before fetching, so skills created in the same session appear immediately. `submitSkillSave()` also nulls `_cronSkillsCache` after a successful write, mirroring the existing `_skillsData = null` pattern. Fixes #502. ### Features **System (auto) theme following OS prefers-color-scheme** (#504 / PRs #506, #509, #514 by @armorbreak001, @cloudyun888) New "System (auto)" option in the theme picker follows the OS dark/light preference via `window.matchMedia`. Changes: - `static/boot.js`: `_applyTheme(name)` helper resolves 'system' via matchMedia, sets `data-theme`, and registers a MQ change listener for live OS tracking. `loadSettings()` calls `_applyTheme()` instead of direct assignment. - `static/index.html`: flicker-prevention script resolves 'system' before first paint. Adds "System (auto)" as first theme option. onchange calls `_applyTheme()`. - `static/commands.js`: adds 'system' to valid `/theme` names. - `static/panels.js`: `_settingsThemeOnOpen` reads from localStorage (preserves 'system' string). `_revertSettingsPreview` calls `_applyTheme()`. - `static/i18n.js`: cmd_theme description lists 'system' first in all 5 locales. ### Tests 22 new tests in `tests/test_batch_fixes.py`. Total tests: 1268 (was 1246) ## [v0.50.46] feat: clarify dialog flow and refresh recovery (#520) Adds a full clarify dialog UX for interactive agent questions — modeled after the approval card but for free-form clarification prompts. ### Backend New `api/clarify.py` module with a per-session pending queue backed by `threading.Event` unblocking, gateway notify callbacks, duplicate deduplication while unresolved, and resolve/clear helpers. Three new HTTP endpoints in `api/routes.py`: - `GET /api/clarify/pending` — poll for pending clarify prompt - `POST /api/clarify/respond` — resolve the pending prompt - `GET /api/clarify/inject_test` — loopback-only, for automated tests `api/streaming.py` wires `clarify_callback` into `AIAgent.run_conversation()`. Emits `clarify` SSE events; blocks the tool flow until the user responds, times out (120s), or the stream is cancelled. Also adds a 409 guard on `chat/start` so page-refresh races return the active stream id instead of starting a duplicate. ### Frontend `static/messages.js`: clarify card with numbered choices, Other button, and free-text input. Composer is locked while clarify is active. DOM self-heals if the card node is removed during a rerender. SSE `clarify` event listener plus 1.5s fallback polling. Session switch and reconnect start/stop clarify polling. 409 conflict flow reattaches to the active stream and queues the user message. `CLARIFY_MIN_VISIBLE_MS = 30000` timer dedup mirrors the approval card pattern. `static/ui.js`: `lockComposerForClarify()` / `unlockComposerForClarify()` with saved-state restore. `updateSendBtn()` respects the disabled state. `static/sessions.js`: `loadSession()` starts/stops clarify polling on switch and inflight reattach. `static/index.html` / `static/style.css`: clarify card markup with ARIA roles and full responsive/mobile styles. `static/i18n.js`: 6 new keys in all 5 locales (en, es, de, zh-Hans, zh-Hant). ### Tests - `tests/test_clarify_unblock.py`: 14 new tests covering queue resolution, notify callbacks, clear-on-cancel, and all three HTTP endpoints. - `tests/test_sprint30.py`: 31 new clarify tests (HTML markup, CSS classes, i18n keys, messages.js functions, streaming registration flags). - `tests/test_sprint36.py`: expand search window for `setBusy` check after additional `stopClarifyPolling()` calls push it past the old 800-char limit. Total tests: 1246 (was 1209) Co-authored-by: franksong2702 ## [v0.50.45] fix: suppress N/A source_tag in session list (#429) Feishu and WeChat sessions (and any session with an unrecognised or legacy `source` value in hermes-agent's state.db) were showing "N/A" or raw tag strings in the session list sidebar. Three fixes in `static/sessions.js`: 1. `_formatSourceTag()` now returns `null` for unrecognised tags instead of the raw string. Known platforms (telegram, discord, slack, feishu, weixin, cli) still display their human-readable label. Unknown/legacy values are silently suppressed. 2. The `metaBits` push is guarded: stores the result in `_stLabel` and only pushes if it is non-null. Prevents `null` or unrecognised platform names from appearing in the session metadata line. 3. The `[SYSTEM:]` title fallback now uses `_SOURCE_DISPLAY[s.source_tag] || 'Gateway'` — the raw `s.source_tag` middle term is removed so a session whose source is "N/A" does not use that as its visible title. No backend changes. The upstream issue (hermes-agent not reliably setting `source` for older Feishu/WeChat sessions) is tracked separately. 7 new tests in `tests/test_issue429.py`. Updated 1 existing test in `tests/test_sprint40_ui_polish.py` to match the new guarded push pattern. - Total tests: 1202 (was 1195) ## [v0.50.44] fix: code-in-table CSS sizing + markdown image rendering (#486, #487) **CSS: inline code inside table cells** (fixes #486) Inline `` `code` `` spans inside `` and `` cells were rendering too large relative to the cell height — the `.msg-body code` rule sets `12.5px` which sits awkward against the table's `12px` base font. Fix: added two targeted rules in `static/style.css`: .msg-body td code,.msg-body th code { font-size:0.85em; padding:1px 4px; vertical-align:baseline; } .preview-md td code,.preview-md th code { font-size:0.85em; padding:1px 4px; vertical-align:baseline; } Covers both the chat message surface (`.msg-body`) and the markdown preview panel (`.preview-md`). **JS renderer: `![alt](url)` image syntax** (fixes #487) Standard markdown image syntax was not handled by `renderMd()`. The `!` was left as a stray character and `[alt](url)` was consumed by the link pass, producing `! alt` instead of an ``. Fix: added an image pass to both `inlineMd()` (for images in table cells, list items, blockquotes, headings) and the outer `renderMd()` pipeline (for images in plain paragraphs): - Regex: `![alt](https?://url)` — only `http://` and `https://` URIs accepted; `javascript:` and `data:` URIs cannot match. - Alt text passes through `esc()` — XSS-safe. - URL double-quotes percent-encoded to `%22` — attribute breakout prevented. - Reuses `.msg-media-img` class — same click-to-zoom and max-width styling as agent-emitted `MEDIA:` images. - `img` added to `SAFE_TAGS` allowlist so the generated `` is not escaped. - In `inlineMd()`: image pass runs while the `_code_stash` is still active, so `![alt](url)` inside a backtick span stays protected and is never rendered as an image. A new `_img_stash` (`\x00G`) protects rendered `` tags from the autolink pass touching `src=` values. **Tests** 45 new tests in `tests/test_issue486_487.py`: - 13 CSS source checks and rendering tests for #486 - 22 JS source checks and rendering tests for #487 - 10 combination edge cases (code + image + link all in same table) - Total tests: 1195 (was 1150) ## [v0.50.43] fix: markdown link rendering + KaTeX CSP fonts **Markdown link rendering — `renderMd()` in `static/ui.js`** (PR #475, fixes #470) Three related bugs fixed: 1. **Double-linking via autolink pass** — `[label](url)` was converted to ``, then the bare-URL autolink pass re-matched the URL sitting inside `href="..."` and wrapped it in a second `` tag. Fixed with three stash/restore layers: `\x00L` (inlineMd labeled links), `\x00A` (existing `` tags before outer link pass), `\x00B` (existing `` tags before autolink pass). 2. **`esc()` on `href` values corrupts query strings** — `esc()` is HTML-entity encoding; applying it to URLs converted `&` → `&` in query strings. Removed `esc()` from href values in all three locations. Display text (link labels) still uses `esc()` for XSS safety. `"` in URLs replaced with `%22` (URL encoding) to close the attribute-injection vector identified during review. 3. **Backtick code spans inside `**bold**` rendered as `<code>`** — `esc()` was applied to code spans after bold/italic processing. Added `\x00C` stash to protect backtick spans in `inlineMd()` before bold/italic regex runs. **Security audit:** `javascript:` injection blocked by `https?://` prefix requirement. `"` attribute breakout fixed by `.replace(/"/g, '%22')`. Label/display text still HTML-escaped. 24 tests in `tests/test_issue470.py`. **KaTeX CSP font-src** (fixes #477) `api/helpers.py` CSP `font-src` now includes `https://cdn.jsdelivr.net` so KaTeX math rendering fonts load correctly. Previously ~50 CSP font-blocking errors appeared in the console on any page with math content. The CDN was already allowed in `script-src` and `style-src` for KaTeX JS/CSS — this extends the same allowance to fonts. 3 tests in `tests/test_issue477.py`. - Total tests: 1150 (was 1130) ## [v0.50.42] fix: session display + model UX polish (sprint 42) **Context indicator always shows latest usage** (PR #471, fixes #437) The context ring/indicator in the composer footer was reading token counts and cost from the stored session snapshot with `||` — meaning stale non-zero values from previous turns always won over a fresh `0` from the current turn. Replaced all six field merges with a `_pick(latest, stored, dflt)` helper that correctly prefers the latest usage when it's a real value (including `0`). **System prompt no longer leaks as gateway session title** (PR #472, fixes #441) Telegram, Discord, and CLI gateway sessions inject a system message before any user turn. When the session title is set from this message, the sidebar shows `[SYSTEM: The user has inv...` instead of a meaningful name. Added a guard in `_renderOneSession()`: if `cleanTitle` starts with `[SYSTEM:`, replace it with the platform display name (`Telegram session`, `Discord session`, etc.). **Thinking/reasoning panel persists across page reload** (PR #473, fixes #427) The full chain-of-thought from Claude, Gemini, and DeepSeek thinking models was lost after streaming completed and on every page reload. Two-part fix: - `api/streaming.py`: `on_reasoning()` now accumulates `_reasoning_text`; before the session is serialised at stream end, `_reasoning_text` is injected into the last assistant message so it's stored in the session JSON - `static/messages.js`: in the `done` SSE handler, `reasoningText` is also patched onto the last assistant message as a belt-and-suspenders client-side fallback **Custom model ID input in model picker** (PR #474, fixes #444) Users who need a model not in the curated list (~30 models) can now type any model ID directly in the dropdown. A text input at the bottom of the model picker lets users enter any string (e.g. `openai/gpt-5.4`, `deepseek/deepseek-r2`, or any provider-prefixed ID) and press Enter or click + to use it immediately. i18n keys added to en, es, zh. - Total tests: 1130 (was 1117) ## [v0.50.41] feat(ui): render MEDIA: images inline in web UI chat (fixes #450) When the agent outputs `MEDIA:` tokens — screenshots from the browser tool, generated images, vision outputs — the web UI now renders them **inline in the chat**, the same way Claude.ai handles images. No more relaying screenshots through Telegram. **How it works:** - Local image path (`MEDIA:/tmp/screenshot.png`): rendered as `` via `/api/media?path=...` - HTTP(S) URL to image (`MEDIA:https://example.com/img.png`): `` directly from the URL - Non-image file (`MEDIA:/tmp/report.pdf`): styled download link (📎 filename) - Click any inline image to toggle full-size zoom **New endpoint — `GET /api/media?path=`:** - Path allowlist: `~/.hermes/`, `/tmp/`, active workspace — covers all agent output locations - Auth-gated: requires valid session cookie when auth is enabled - Inline image MIME types: PNG, JPEG, GIF, WebP, BMP - SVG always served as download attachment (XSS prevention) - RFC 5987-compliant `Content-Disposition` headers (handles Unicode filenames) - `Cache-Control: private, max-age=3600` **Security:** - Original version had `~` (entire home dir) as an allowed root — **fixed** by independent reviewer - Restricted to `~/.hermes/`, `/tmp/`, and active workspace only - `Path.resolve()` + `commonpath` checks prevent symlink traversal **Changes:** - `api/routes.py`: `_handle_media()` handler + `/api/media` route - `static/ui.js`: `MEDIA:` stash in `renderMd()` (runs before `fence_stash`, stash token `\x00D`) - `static/style.css`: `.msg-media-img` (480px max-width, zoom-on-click), `.msg-media-link` - `tests/test_media_inline.py`: 19 new tests (static analysis + integration) - Total tests: 1117 (was 1098) ## [v0.50.40] feat: session UI polish + parallel test isolation **Session sidebar improvements:** - `static/sessions.js` + `style.css`: Hide session timestamps to give titles full available width — no more title truncation from inline timestamps (PR #449) - `static/style.css`: Active session title now uses `var(--gold)` theme variable instead of hardcoded `#e8a030` — adapts correctly across all 7 themes (PR #451, fixes #440) - `api/models.py` + `api/gateway_watcher.py`: Return `None` instead of the string `'unknown'` for missing gateway session model — Telegram sessions no longer show `telegram · unknown` (PR #452, fixes #443) - `static/style.css` + `static/sessions.js`: Mute Telegram badge from saturated `#0088cc` to `rgba(0, 136, 204, 0.55)`. Add `_formatSourceTag()` helper mapping platform IDs to display names (`telegram` → `via Telegram`) (PR #453, fixes #442) **Bug fixes:** - `api/config.py` `resolve_model_provider()`: Strip provider prefix from model ID when a custom `base_url` is configured (`openai/gpt-5.4` → `gpt-5.4`) — fixes broken chats after switching to a custom endpoint (PR #454, fixes #433) - `static/panels.js` `switchToProfile()`: Apply profile default workspace to new session created during profile switch — workspace chip no longer shows "No active workspace" after switching profiles mid-conversation (PR #455, fixes #424) **Test infrastructure:** - `tests/conftest.py` + `tests/_pytest_port.py` (new): Auto-derive unique port and state dir per worktree from repo path hash (range 20000-29999). Running pytest in two worktrees simultaneously no longer causes port conflicts. All 43 test files updated from hardcoded `BASE = "http://127.0.0.1:8788"` to `from tests._pytest_port import BASE` (PR #456) - Total tests: 1098 (was 1078) ## [v0.50.39] fix: orphan gateway sessions + first-password-enablement session continuity Two bug fixes: **PR #423 — Fix orphan gateway sessions in sidebar (@aronprins, fix by maintainer)** `gateway_watcher.py`'s `_get_agent_sessions_from_db()` was missing the `HAVING COUNT(m.id) > 0` clause that `get_cli_sessions()` already had. Sessions with no messages (e.g. created then abandoned before any turns) would appear in the sidebar via the SSE watcher stream even after the initial page load filtered them out. One-line SQL fix applied to both query paths. **PR #434 — First-password-enablement session continuity (@SaulgoodMan-C)** When a user enables a password for the first time via POST `/api/settings`, the current browser session was being terminated — requiring the user to log in again immediately after setting their password. Fix: the response now includes `auth_enabled`, `logged_in`, and `auth_just_enabled` fields, and issues a `hermes_session` cookie when auth is first enabled, so the browser remains logged in. Also: legacy `assistant_language` key is now dropped from settings on next save. New i18n keys for password replacement/keep-existing states (en, es, de, zh, zh-Hant). - `api/config.py`: `_SETTINGS_LEGACY_DROP_KEYS` removes `assistant_language` on load - `api/routes.py`: first-password-enable session continuity with `auth_just_enabled` flag - `static/panels.js`: `_setSettingsAuthButtonsVisible()` + `_applySavedSettingsUi()` helpers - `static/i18n.js`: password state i18n keys across 5 locales - `tests/test_sprint45.py`: 3 new integration tests (auth continuity + legacy key cleanup) - Total tests: 1078 (was 1075) ## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n Three community contributions combined: **PR #425 — Remove mobile bottom nav (@aronprins)** The fixed iOS-style bottom navigation bar on phones has been removed. The sidebar drawer tabs already handle all navigation — the bottom nav was redundant and consumed ~56px of vertical chat space. `test_mobile_layout.py` updated with `test_mobile_bottom_nav_removed()` and new sidebar nav coverage tests. **PR #426 — Prism syntax highlighting with light + dark theme token colors (@GiggleSamurai)** Fenced code blocks now emit `class="language-{lang}"` on `` elements, enabling Prism's autoloader to apply token-level syntax highlighting. Added 36-line `:root[data-theme="light"]` token color overrides scoped to light theme only; dark/dim/monokai/nord themes unaffected. Background guard uses `var(--code-bg) !important` to prevent Prism's dark background from overriding theme variables. 2 new regression tests in `test_issue_code_syntax_highlight.py`. **PR #428 — zh-CN/zh-Hant i18n hardening (@vansour)** Pluggable `resolvePreferredLocale()` function with smart zh-CN/zh-SG/zh-TW/zh-HK variant mapping. Full zh-Simplified and zh-Traditional locale blocks added to `i18n.js`. Login page locale routing updated in `api/routes.py` (`_resolve_login_locale_key()` helper). Hardcoded strings in `panels.js` cron UI extracted to i18n keys. 3 new test files: `test_chinese_locale.py`, `test_language_precedence.py`, `test_login_locale.py`. - Total tests: 1075 (was 1063) ## [v0.50.37] fix(onboarding): skip wizard when Hermes is already configured Fixes #420 — existing Hermes users with a valid `config.yaml` were shown the first-run onboarding wizard on every WebUI load because the only completion gate was `settings.onboarding_completed` in the WebUI's own settings file. Users who configured Hermes via the CLI before the WebUI existed had no such flag, so the wizard always fired and could silently overwrite their working config. **Changes:** 1. `api/onboarding.py` `get_onboarding_status()`: auto-complete when `config.yaml` exists AND `chat_ready=True`. Existing configured users are never shown the wizard. 2. `api/onboarding.py` `apply_onboarding_setup()`: refuse to overwrite an existing `config.yaml` without `confirm_overwrite=True` in the request body. Returns `{error: "config_exists", requires_confirm: true}` for the frontend to handle. 3. `static/index.html`: "Skip setup" button added to wizard footer — users are never trapped in the wizard. 4. `static/onboarding.js`: `skipOnboarding()` calls `/api/onboarding/complete` without modifying config, then closes the overlay. 5. `static/boot.js`: Escape key now dismisses the onboarding overlay. 6. `static/i18n.js`: `onboarding_skip` / `onboarding_skipped` keys added to en + es locales. 7. `tests/test_onboarding_existing_config.py`: 8 new unit tests covering gate logic and overwrite guard. - Total tests: 1063 (was 1055) ## [v0.50.36] fix: workspace list cleaner — allow own-profile paths, remove brittle string filter Two bugs in `_clean_workspace_list()` caused workspace additions to silently disappear on the next `load_workspaces()` call, breaking `test_workspace_add_no_duplicate` and `test_workspace_rename` (and potentially causing real-world workspace list corruption): **Bug 1 — Brittle string filter removed:** `if 'test-workspace' in path or 'webui-mvp-test' in path: continue` dropped any workspace path containing those substrings. In the test server, `TEST_WORKSPACE` is `~/.hermes/profiles/webui/webui-mvp-test/test-workspace`, so every workspace added during tests was silently discarded on the next `load_workspaces()` call. The `p.is_dir()` check already handles genuinely non-existent paths — the string filter was redundant and harmful. **Bug 2 — Cross-profile filter was too broad:** `if p is under ~/.hermes/profiles/: skip` was designed to block cross-profile workspace leakage, but it also removed paths under the *current* profile's own directory (e.g. `~/.hermes/profiles/webui/...`). Fixed: now only skips paths under `profiles/` that are NOT under the current profile's own `hermes_home`. - `api/workspace.py`: remove string-match filter; fix cross-profile check to allow own-profile paths - All 1055 tests now pass (was 1053 pass + 2 fail) ## [v0.50.35] fix: workspace trust boundary — cross-platform, multi-workspace support v0.50.34's workspace trust check was too restrictive: it required all workspaces to be under `DEFAULT_WORKSPACE` (/home/hermes/workspace), which blocked every profile-specific workspace (~/CodePath, ~/hermes-webui-public, ~/WebUI, ~/Camanji, etc.) and prevented switching between workspaces at all. Replaced with a three-layer model that works cross-platform and supports multiple workspaces per profile: 1. **Blocklist** — `/etc`, `/usr`, `/var`, `/bin`, `/sbin`, `/boot`, `/proc`, `/sys`, `/dev`, `/root`, `/lib`, `/lib64`, `/opt/homebrew` always rejected, closing the original CVSS 8.8 vulnerability 2. **Home-directory check** — any path under `Path.home()` is trusted; `Path.home()` is cross-platform (`~/...` on Linux/macOS, `C:\\Users\\...` on Windows); allows all profile workspaces simultaneously since they don't need to share a single ancestor 3. **Saved-workspace escape hatch** — paths already in the profile's saved workspace list are trusted regardless of location, covering self-hosted deployments with workspaces outside home (`/data/projects`, `/opt/workspace`, etc.) - `api/workspace.py`: rewritten `resolve_trusted_workspace()` with the three-layer model - `tests/test_sprint3.py`: updated error-message assertions from `"trusted workspace root"` → `"outside"` (covers both old and new error strings) - 1053 tests total (unchanged) ## [v0.50.34] fix(workspace): restrict session workspaces to trusted roots [SECURITY] (#415) Session creation, update, chat-start, and workspace-add endpoints accepted arbitrary caller-supplied workspace paths. An authenticated caller could repoint a session to any directory the process could access, then use normal file read/write APIs to operate on attacker-chosen locations. CVSS 8.8 High (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). - `api/workspace.py`: new `resolve_trusted_workspace(path)` helper — resolves path, checks existence + is_dir, enforces `path.relative_to(_BOOT_DEFAULT_WORKSPACE)` containment; requests outside the WebUI workspace root fail with 400 - `api/routes.py`: apply `resolve_trusted_workspace()` to all four entry points — `POST /api/session/new`, `POST /api/session/update`, `POST /api/chat/start` (workspace override), `POST /api/workspaces/add` - `tests/test_sprint3.py`, `tests/test_sprint5.py`: regression tests for rejected outside-root paths on all four entry points; existing workspace tests updated to use trusted child directories - `tests/test_sprint1.py`, `tests/test_sprint4.py`, `tests/test_sprint13.py`: aligned to new trusted-root contract - Fix: use `_BOOT_DEFAULT_WORKSPACE` (respects `HERMES_WEBUI_DEFAULT_WORKSPACE` env for test isolation) rather than `_profile_default_workspace()` (reads agent terminal.cwd which may differ) - Original PR by @Hinotoi-agent (cherry-picked; branch was 6 commits behind master) - 1053 tests total (up from 1051; 2 pre-existing test_sprint5 isolation failures on master, not introduced by this PR) ## [v0.50.33] fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#413) **Bug 1 — Duplicate X on desktop:** `#btnClearPreview` (the X icon) was always visible regardless of panel state, so desktop browse mode showed both the chevron collapse button and the X simultaneously. Fixed in `syncWorkspacePanelUI()`: on non-compact (desktop) viewports, `clearBtn.style.display` is set to `none` when no file preview is open, and cleared (shown) when a preview is active. **Bug 2 — Mobile X collapsed the whole panel instead of dismissing the file:** `.mobile-close-btn` was wired to `closeWorkspacePanel()` directly, bypassing the two-step close logic. Fixed by changing `onclick` to `handleWorkspaceClose()`, which calls `clearPreview()` first if a file is open, and falls through to `closeWorkspacePanel()` otherwise. **Also:** widened the `test_server_delete_invalidates_index` window from 600 → 1200 chars to accommodate the session_id validation guards added in v0.50.32 (#412). - `static/boot.js`: `syncWorkspacePanelUI()` sets `clearBtn.style.display` based on `hasPreview` when `!isCompact` - `static/index.html`: `.mobile-close-btn` onclick changed from `closeWorkspacePanel()` to `handleWorkspaceClose()` - `tests/test_sprint44.py`: 10 new regression tests covering both fixes - `tests/test_mobile_layout.py`: updated to accept `handleWorkspaceClose()` as valid onclick - `tests/test_regressions.py`: widened delete handler window to 1200 chars - 1051 tests total (up from 1041) ## [v0.50.32] fix(sessions): validate session_id before deleting session files [SECURITY] (#409) `/api/session/delete` accepted arbitrary `session_id` values from the request body and built the delete path directly as `SESSION_DIR / f"{sid}.json"`. Because pathlib discards the prefix when `sid` is an absolute path, an attacker could supply `/tmp/victim` and cause the server to unlink `victim.json` outside the session store. Traversal-style values (`../../etc/target`) were also accepted. CVSS 8.1 High (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H). - `api/routes.py`: validate `session_id` against `[0-9a-z_]+` allowlist (covers `uuid4().hex[:12]` WebUI IDs and `YYYYMMDD_HHMMSS_hex` CLI IDs) before path construction; resolve candidate path and enforce `path.relative_to(SESSION_DIR)` containment before unlinking; only invalidate session index on successful deletion path, not on rejected requests - `tests/test_sprint3.py`: 2 new regression tests — absolute-path payload rejected and file preserved, traversal payload rejected and file preserved - Original PR by @Hinotoi-agent (cherry-picked; branch was 4 commits behind master) - 1041 tests total (up from 1039) ## [v0.50.31] fix: delegate all live model fetching to agent's provider_model_ids() `_handle_live_models()` in `api/routes.py` previously maintained its own per-provider fetch logic and returned `not_supported` for Anthropic, Google, and Gemini. Now it delegates entirely to the agent's `hermes_cli.models.provider_model_ids()` — the single authoritative resolver — and `_fetchLiveModels()` in `ui.js` no longer skips any provider. **What each provider now returns (live data where credentials are present, static fallback otherwise):** - `anthropic` — live from `api.anthropic.com/v1/models` (API key or OAuth token with correct beta headers) - `copilot` — live from `api.githubcopilot.com/models` with required Copilot headers - `openai-codex` — Codex OAuth endpoint → `~/.codex/` cache → `DEFAULT_CODEX_MODELS` - `nous` — live from Nous inference portal - `deepseek`, `kimi-coding` — generic OpenAI-compat `/v1/models` - `opencode-zen`, `opencode-go` — OpenCode live catalog - `openrouter` — curated static list (live returns 300+ which floods the picker) - `google`, `gemini`, `zai`, `minimax` — static list (non-standard or Anthropic-compat endpoints) - All others — graceful static fallback from `_PROVIDER_MODELS` The hardcoded lists in `_PROVIDER_MODELS` remain as credential-missing / network-unavailable fallbacks. `api/routes.py` shrank by ~100 lines. Updated 2 tests to reflect the improved behavior. - 1039 tests total (up from 1038) ## [v0.50.30] fix: openai-codex live model fetch routes through agent's get_codex_model_ids() `_handle_live_models()` was grouping `openai-codex` with `openai` and sending `GET https://api.openai.com/v1/models` — which returns 403 because Codex auth is OAuth-based via `chatgpt.com`, not a standard API key. The live fetch silently failed, so users only ever saw the hardcoded static list. - `api/routes.py`: dedicated early-return branch for `openai-codex` that calls `hermes_cli.codex_models.get_codex_model_ids()` — the same resolver the agent CLI uses. Resolution order: live Codex API (if OAuth token available, hits `chatgpt.com/backend-api/codex/models`) → `~/.codex/` local cache (written by the Codex CLI) → `DEFAULT_CODEX_MODELS` hardcoded fallback. Users with a valid Codex session now get their exact subscription model list including any models not in the hardcoded list. - `api/routes.py`: improved label generation for Codex model IDs (e.g. `gpt-5.4-mini` → `GPT 5.4 Mini`) - `tests/test_opencode_providers.py`: structural regression test verifying the dedicated `openai-codex` branch exists and calls `get_codex_model_ids()` - 1038 tests total (up from 1037) ## [v0.50.29] fix: correct tool call card rendering on session load after context compaction (closes #401) (#402) - `static/sessions.js`: replace the flat B9 filter in `loadSession()` with a full sanitization pass that builds `origIdxToSanitizedIdx` — each `session.tool_calls[].assistant_msg_idx` is remapped to the new sanitized-array position as messages are filtered; for tool calls whose empty-assistant host was filtered out, they attach to the nearest prior kept assistant - `static/sessions.js`: set `S.toolCalls=[]` instead of pre-filling from session-level `tool_calls` — this lets `renderMessages()` use its fallback derivation from per-message `tool_calls` (which already carry correct indices into the sanitized message array); the fix eliminates the "200+ tool cards all on the wrong message" symptom on context-compacted session load - `tests/test_issue401.py`: 8 regression tests — 4 static structural checks and 4 behavioural Node.js tests covering index remapping, multiple consecutive empty assistants, no-filtering pass-through, and `tool`-role message exclusion - Original PR by @franksong2702 (cherry-picked onto master; branch was 31 commits behind) - 1037 tests total (up from 1029) ## [v0.50.28] fix: expand openai-codex model catalog to match DEFAULT_CODEX_MODELS `_PROVIDER_MODELS["openai-codex"]` only listed `codex-mini-latest`, so profiles using the `openai-codex` provider (e.g. a CodePath profile with `default: gpt-5.4`) showed only one entry in the model dropdown. Updated to mirror the agent's authoritative `DEFAULT_CODEX_MODELS` list: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `codex-mini-latest`. Added 2 regression tests. - 1029 tests total (up from 1027) ## [v0.50.27] feat: relative time labels in session sidebar (#394) - `static/sessions.js`: new `_sessionCalendarBoundaries()` (DST-safe via `new Date(y,m,d)` construction), `_localDayOrdinal()`, `_formatSessionDate()` (includes year for dates from prior years); `_formatRelativeSessionTime()` now uses calendar midnight boundaries consistent with `_sessionTimeBucketLabel()` — no more label/bucket mismatch; all relative time strings call `t()` for localization; meta row only appended when non-empty (removes redundant group-header fallback); dead `ONE_DAY` constant removed - `static/style.css`: add `session-item.active .session-title{color:#1a5a8a}` to light-theme block (fixes active title color in light mode) - `static/i18n.js`: 11 new i18n keys (`session_time_*`) in both English and Spanish locale blocks; callable keys use arrow-function pattern consistent with existing `n_messages` - `tests/test_session_sidebar_relative_time.py`: 5 tests — structural presence checks, behavioral Node.js tests via subprocess (yesterday/week boundary correctness, `just now` threshold, year-in-date for old sessions, full i18n key coverage for en+es) - Original PR by @Jordan-SkyLF (two-pass review: blocking issues fixed in second commit) - 1027 tests total (up from 1022) ## [v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400) - `api/routes.py`: apply `_redact_text()` to session titles in all four response paths — `/api/sessions` merged list, `/api/sessions/search` empty-q, title-match, and content-match; use `dict(s)` copy before mutating to avoid corrupting the in-memory session cache - `tests/test_session_summary_redaction.py`: 2 integration tests verifying `sk-` prefixed secrets in session titles are redacted from both list and search endpoint responses - Original PR by @Hinotoi-agent (note: fix commit had a display artifact — `sk-` prefix was visually rendered as `***` in terminal output but the actual bytes were correct and the token was recognized by the redaction engine) - 1022 tests total (up from 1020) ## [v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback ### fix: restore mobile chat scrolling and drawer close (#397) - `static/style.css`: `min-height:0` on `.layout` and `.main` (flex shrink chain fix); `-webkit-overflow-scrolling:touch`, `touch-action:pan-y`, `overscroll-behavior-y:contain` on `.messages` - `static/boot.js`: call `closeMobileSidebar()` on new-conversation button and Ctrl+K shortcut so the transcript is visible immediately after starting a chat - `tests/test_mobile_layout.py`: 41 new lines covering CSS fixes and both JS call sites - Original PR by @Jordan-SkyLF ### fix: preserve imported session timestamps (#395) - `api/models.py`: `Session.save(touch_updated_at=True)` — new flag; `import_cli_session()` accepts `created_at`/`updated_at` kwargs and saves with `touch_updated_at=False` - `api/routes.py`: extract `created_at`/`updated_at` from `get_cli_sessions()` metadata and forward to import; post-import save also uses `touch_updated_at=False` - `tests/test_gateway_sync.py`: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly; also fix session file cleanup in test finally block - Original PR by @Jordan-SkyLF ### fix(profiles): block path traversal in profile switch and delete flows (#399) [SECURITY] - `api/profiles.py`: new `_resolve_named_profile_home(name)` — validates name via `^[a-z0-9][a-z0-9_-]{0,63}$` regex then enforces path containment via `candidate.resolve().relative_to(profiles_root)`; use in `switch_profile()` - `api/profiles.py`: add `_validate_profile_name()` call to `delete_profile_api()` entry - `api/routes.py`: add `_validate_profile_name()` at HTTP handler level for both `/api/profile/switch` and `/api/profile/delete` - `tests/test_profile_path_security.py`: 3 new tests — traversal rejected, valid name passes (cherry-picked from @Hinotoi-agent's PR, which was 62 commits behind master) ### feat: add desktop microphone transcription fallback (#396) - `static/boot.js`: detect `_canRecordAudio`; keep mic button enabled when MediaRecorder available even without SpeechRecognition; full MediaRecorder recording → `/api/transcribe` fallback path with proper cleanup and error handling - `api/upload.py`: add `transcribe_audio()` helper — temp file, calls transcription_tools, always cleans up - `api/routes.py`: add `/api/transcribe` POST handler — CSRF-protected, auth-gated, 20MB limit - `api/helpers.py`: change `Permissions-Policy` `microphone=()` → `microphone=(self)` (required for getUserMedia) - `tests/test_voice_transcribe_endpoint.py`: 87 new lines (3 tests with mocked transcription) - `tests/test_sprint19.py`: regression guard for microphone Permissions-Policy - `tests/test_sprint20.py`: 3 updated tests for new fallback capability checks - Original PR by @Jordan-SkyLF - 1020 tests total (up from 1003) ## [v0.50.24] feat: opt-in chat bubble layout (closes #336) - `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save - `static/style.css`: 11 lines of CSS-only bubble layout — user rows `align-self:flex-end` / max-width 75%, assistant rows `flex-start`, all gated on `body.bubble-layout` class so the default full-width canvas is untouched; 700px responsive rule widens to 92% - `static/boot.js`: Apply `body.bubble-layout` class from settings on page load; explicitly remove the class in the catch path so the feature stays off on API failure - `static/panels.js`: Load checkbox state in `loadSettingsPanel`; write `body.bubble_layout` in `saveSettings` and immediately toggle `body.bubble-layout` class for live preview without a page reload - `static/index.html`: Checkbox in the Appearance settings group, positioned between Show token usage and Show agent sessions - `static/i18n.js`: English label + description keys; Spanish translations included in the same PR - `tests/test_issue336.py`: 22 new tests covering config registration, JS class management in boot and panels, CSS selectors, HTML structure, i18n coverage for en+es, and API round-trip (default false, persist true/false, bool coercion) - 1003 tests total (up from 981) ## [v0.50.23] Add OpenCode Zen and Go provider support (fixes #362) - `api/config.py`: Add `opencode-zen` and `opencode-go` to `_PROVIDER_DISPLAY` — providers now show human-readable names in the UI instead of raw IDs - `api/config.py`: Add full model catalogs for both providers to `_PROVIDER_MODELS` — Zen (pay-as-you-go credits, 32 models) and Go (flat-rate $10/month, 7 models) now show the correct model list in the dropdown instead of falling through to the unknown-provider fallback - `api/config.py`: Add `OPENCODE_ZEN_API_KEY` / `OPENCODE_GO_API_KEY` to the env-var fallback detection path — providers are correctly detected as authenticated when keys are set in `.env` - `tests/test_opencode_providers.py`: 6 new tests covering display registration, model catalog registration, and env-var detection for both providers - 985 tests total (up from 979) ## [v0.50.22] Onboarding unblocked for reverse proxy / SSH tunnel deployments (fixes #390) - `api/routes.py`: Onboarding setup endpoint now reads `X-Forwarded-For` and `X-Real-IP` headers before falling back to raw socket IP — reverse proxy (nginx/Caddy/Traefik) and SSH tunnel users are no longer incorrectly blocked - Added `HERMES_WEBUI_ONBOARDING_OPEN=1` env var escape hatch for operators on remote servers who control network access themselves - Error message now includes the env var hint so users know how to unblock themselves - 18 new tests covering all IP resolution paths (`TestOnboardingIPLogic`, `TestOnboardingSetupEndpoint`) > Living document. Updated at the end of every sprint. > Repository: https://github.com/nesquena/hermes-webui --- ## [v0.50.21] Live reasoning, tool progress, and in-flight session recovery (PR #367) - **Durable inflight reload recovery** (`static/ui.js`, `static/messages.js`): `saveInflightState` / `loadInflightState` / `clearInflightState` backed by `localStorage` (`hermes-webui-inflight-state` key, per-session, 10-minute TTL). Snapshots are saved on every token, tool event, and tool completion, and cleared when the run ends/errors/cancels. On a full page reload with an active stream, `loadSession()` hydrates from the snapshot before calling `attachLiveStream(..., {reconnecting:true})` — partial messages, live tool cards, and reasoning text all survive the reload. - **Live reasoning cards during streaming** (`static/ui.js`, `static/messages.js`): The generic thinking spinner now upgrades to a live reasoning card when the backend streams reasoning text. `_thinkingMarkup(text)` and `updateThinking(text)` centralize the markup so the spinner and card share the same DOM slot. Works with models that emit reasoning via the agent's `reasoning_callback` or `tool_progress_callback`. - **`tool_complete` SSE events** (`api/streaming.py`, `static/messages.js`): Tool progress callback now accepts the current agent signature `on_tool(*cb_args, **cb_kwargs)` — handles both the old 3-arg `(name, preview, args)` form and the new 4-arg `(event_type, name, preview, args)` form. `tool.completed` events transition live tool cards from running to done cleanly. - **In-flight session state stable across switches** (`static/messages.js`, `static/sessions.js`): `attachLiveStream` refactored out of `send()` into a standalone function; partial assistant text mirrored into `INFLIGHT` state on every token; `data-live-assistant` DOM anchor preserved across `renderMessages()` calls so switching away and back doesn't lose or duplicate live output. - **Reload recovery** (`api/models.py`, `api/routes.py`, `api/streaming.py`, `static/sessions.js`): `active_stream_id`, `pending_user_message`, `pending_attachments`, and `pending_started_at` now persisted on the session object before streaming starts and cleared on completion (or exception). `/api/session` returns these fields. After a page reload or session switch, `loadSession()` detects `active_stream_id` and calls `attachLiveStream(..., {reconnecting:true})` to reattach to the live SSE stream. - **Session-scoped message queue** (`static/ui.js`, `static/messages.js`): Global `MSG_QUEUE` replaced with `SESSION_QUEUES` keyed by session ID. Queued follow-up messages are associated with the session they were typed in and only drained when that session becomes idle — no cross-session bleed. - **`newSession()` idle reset** (`static/sessions.js`): Sets `S.busy=false`, `S.activeStreamId=null`, clears the cancel button, resets composer status — ensures a fresh chat is immediately usable even if another session's stream is still running. - **Todos survive session reload** (`static/panels.js`): `loadTodos()` now reads from `S.session.messages` (raw, includes tool-role messages) rather than `S.messages` (filtered display), so todo state reconstructed from tool outputs survives reloads. - 12 new regression tests in `tests/test_regressions.py`; 961 tests total (up from 949) ## [v0.50.20] Silent error fix, stale model cleanup, live model fetching (fixes #373, #374, #375) ### Fix: Chat no longer silently swallows agent failures (fixes #373) - **`api/streaming.py`**: After `run_conversation()` completes, the server now checks whether the agent produced any assistant reply. If not (e.g., auth error swallowed internally, model unavailable, network timeout), it emits an `apperror` SSE event with a clear message and type (`auth_mismatch` or `no_response`) instead of silently emitting `done`. A `_token_sent` flag tracks whether any streaming tokens were sent. - **`static/messages.js`**: The `done` handler has a belt-and-suspenders guard — if `done` arrives but no assistant message exists in the session (the `apperror` path should usually catch this first), an inline "**No response received.**" message is shown. The `apperror` handler now also recognises the new `no_response` type with a distinct label. ### Cleanup: Remove stale OpenAI models from default list (fixes #374) - **`api/config.py`**: `gpt-4o` and `o3` removed from `_FALLBACK_MODELS` and `_PROVIDER_MODELS["openai"]`. Both are superseded by newer models already in the list (`gpt-5.4-mini` for general use, `o4-mini` for reasoning). The Copilot provider list retains `gpt-4o` as it remains available via the Copilot API. ### Feature: Live model fetching from provider API (closes #375) - **`api/routes.py`**: New `/api/models/live?provider=openai` endpoint. Fetches the actual model list from the provider's `/v1/models` API using the user's configured credentials. Includes URL scheme validation (B310), SSRF guard (private IP block), and graceful `not_supported` response for providers without a standard `/v1/models` endpoint (Anthropic, Google). Response normalised to `{id, label}` list, filtered to chat models. - **`static/ui.js`**: `populateModelDropdown()` now calls `_fetchLiveModels()` in the background after rendering the static list. Live models that aren't already in the dropdown are appended to the provider's optgroup. Results are cached per session so only one fetch per provider per page load. Skips Anthropic and Google (unsupported). Falls back to static list silently if the fetch fails. - 25 new tests in `tests/test_issues_373_374_375.py`; 949 tests total (up from 924) ## [v0.50.19] Fix UnicodeEncodeError when downloading files with non-ASCII filenames (PR #378) - **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it. - 2 new integration tests in `tests/test_sprint29.py` covering Chinese filenames for both download and inline responses, verifying the header is latin-1 encodable and `filename*=UTF-8''` is present; 924 tests total (up from 922) ## [v0.50.18] Recover from invalid default workspace paths (PR #366) - **WebUI no longer breaks when the configured default workspace is unavailable** (`api/config.py`): The workspace resolution path was refactored into three composable functions — `_workspace_candidates()`, `_ensure_workspace_dir()`, and `resolve_default_workspace()`. When the configured workspace (from env var, settings file, or passed path) cannot be created or accessed, the server falls back through an ordered priority list: `HERMES_WEBUI_DEFAULT_WORKSPACE` env var → `~/workspace` (if exists) → `~/work` (if exists) → `~/workspace` (create it) → `STATE_DIR/workspace`. - **`save_settings()` now validates and corrects the workspace path** (`api/config.py`): If a client posts an invalid or inaccessible `default_workspace`, the saved value is corrected to the nearest valid fallback rather than persisting an unusable path. - **Startup normalizes stale workspace paths** (`api/config.py`): If the settings file stores a workspace that no longer exists, the server rewrites it with the resolved fallback on startup so the problem self-heals. - 7 tests in `tests/test_default_workspace_fallback.py` (2 from PR + 5 added during review: fallback creation, RuntimeError on all-fail, deduplication, env var priority, unwritable path returns False); 922 tests total (up from 915) ## [v0.50.17] Docker: pre-install uv at build time + fix workspace permissions (fixes #357) - **Docker containers no longer need internet access at startup** (`Dockerfile`): `uv` is now installed at image build time via `RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh` (run as root, so `uv` lands in `/usr/local/bin` — accessible to all users). The init script skips the download if `uv` is already on PATH (`command -v uv`), and falls back to downloading with a proper `error_exit` if it isn't. This fixes startup failures in air-gapped, firewalled, or isolated Docker networks where `github.com` is unreachable at runtime. - **Fix applied during review**: the original PR installed `uv` as the `hermeswebuitoo` user (to `~hermeswebuitoo/.local/bin`), which is not on the `hermeswebui` runtime user's `PATH`. Changed to install as `root` with `UV_INSTALL_DIR=/usr/local/bin` so `uv` is in the system PATH for all users. - **Workspace directory now writable by the hermeswebui user** (`docker_init.bash`): The init script now uses `sudo mkdir -p` and `sudo chown hermeswebui:hermeswebui` for `HERMES_WEBUI_DEFAULT_WORKSPACE`. Docker auto-creates bind-mount directories as `root` if they don't exist on the host, making them unwritable by the app user. The `sudo chown` corrects ownership after creation. - 15 new structural tests in `tests/test_issue357.py`; 915 tests total (up from 900) ## [v0.50.16] Fix CSRF check failing behind reverse proxy on non-standard ports (PR #360) - **CSRF no longer rejects POST requests from reverse-proxied deployments on non-standard ports** (`api/routes.py`, fixes #355): When serving behind Nginx Proxy Manager or similar on a port like `:8000`, browsers send `Origin: https://app.example.com:8000` while the proxy forwards `Host: app.example.com` (port stripped). The old string comparison failed this as cross-origin. Two changes fix it: - `_normalize_host_port()`: properly splits host:port strings including IPv6 bracket notation (`[::1]:8080`) - `_ports_match(scheme, origin_port, allowed_port)`: scheme-aware port equivalence — absent port equals `:80` for `http://` and `:443` for `https://`. This prevents the previous cross-protocol confusion where `http://host` could incorrectly match an `https://host:443` server (security fix applied on top of the original PR) - `HERMES_WEBUI_ALLOWED_ORIGINS` env var: comma-separated explicit origin allowlist for cases where port normalization alone isn't sufficient (e.g. non-standard ports like `:8000` where the proxy strips the port entirely). Entries without a scheme (`https://`) are rejected with a startup warning. - **Security fix applied during review**: the original `_ports_match` treated both port 80 and port 443 as interchangeable with "absent port", which is scheme-unaware. An `http://host` origin would pass for an `https://host:443` server. Fixed by making the default-port lookup scheme-specific. - 29 new tests in `tests/test_sprint29.py` (5 from PR + 24 added during review): cover scheme-aware port matching, cross-protocol rejection, unit tests for `_normalize_host_port` and `_ports_match`, allowlist validation, comma-separated origins, no-scheme allowlist warning, the bug scenario with and without the allowlist; 900 tests total (up from 871) ## [v0.50.15] KaTeX math rendering for LaTeX in chat and workspace previews (fixes #347) - **LaTeX / KaTeX math now renders in chat messages and workspace file previews** (`static/ui.js`, `static/workspace.js`, `static/style.css`, `static/index.html`): Inline math (`$...$`, `\(...\)`) and display math (`$$...$$`, `\[...\]`) are rendered via KaTeX instead of displaying as raw text. Follows the existing mermaid lazy-load pattern: delimiters are stashed before markdown processing, placeholder elements are emitted, and KaTeX JS is loaded from CDN on first use — no KaTeX JS is loaded unless math is present. - `$$...$$` and `\[...\]` → centered display math (`
`) - `$...$` and `\(...\)` → inline math (``); requires non-space at `$` boundaries to avoid false positives on currency amounts like `$5` - KaTeX JS lazy-loaded from jsdelivr CDN with SRI hash; KaTeX CSS loaded eagerly in `` to prevent layout shift - `throwOnError:false` — invalid LaTeX degrades to a `` span rather than crashing the message - `trust:false` — disables KaTeX commands that could execute code - `` added to `SAFE_TAGS` allowlist for inline math spans (tag name boundary check preserved) - **Fix: fence stash now runs before math stash** (`static/ui.js`): The original PR had math stash before fence stash, meaning `\`$x$\`` inside backtick code spans was incorrectly extracted as math instead of being protected as code. Order corrected — fence_stash runs first so code spans protect their contents. - **Workspace file previews now render math** (`static/workspace.js`): Added `requestAnimationFrame(renderKatexBlocks)` after markdown file preview renders, matching the chat message path. Without this, math placeholders appeared in previews but were never rendered. - 29 tests in `tests/test_issue347.py` (18 original + 11 new covering stash ordering, workspace wiring, false-positive prevention); 870 tests total (up from 841) ## [v0.50.14] Security fixes: B310 urlopen scheme validation, B324 MD5 usedforsecurity, B110 bare except logging + QuietHTTPServer (PR #354) - **B324 — MD5 no longer triggers crypto warnings** (`api/gateway_watcher.py`): `_snapshot_hash` uses MD5 only as a non-cryptographic change-detection hash. Added `usedforsecurity=False` so systems with strict crypto policies (FIPS mode etc.) don't reject the call. - **B310 — urlopen now validates URL scheme** (`api/config.py`, `bootstrap.py`): Both `get_available_models()` and `wait_for_health()` validate that the URL scheme is `http` or `https` before calling `urllib.request.urlopen`, preventing `file://` or other dangerous scheme injection. Added `# nosec B310` suppression after each validated call. - **B110 — bare `except: pass` blocks replaced with `logger.debug()`** (12 files): All `except Exception: pass` and `except: pass` blocks now log the failure at DEBUG level so operators can diagnose issues in production without changing behavior. A module-level `logger = logging.getLogger(__name__)` was added to each file. - **`QuietHTTPServer`** (`server.py`): Subclass of `ThreadingHTTPServer` that overrides `handle_error()` to silently drop `ConnectionResetError`, `BrokenPipeError`, `ConnectionAbortedError`, and socket errno 32/54/104 (client disconnect races). Real errors still delegate to the default handler. Reduces log spam from SSE clients that disconnect mid-stream. - **Session title redaction** (`api/routes.py`): The `/api/sessions` list endpoint now applies `_redact_text` to session titles before returning them, consistent with the per-session `redact_session_data()` already applied elsewhere. - **Fix**: `QuietHTTPServer.handle_error` uses `sys.exc_info()` (standard library) not `traceback.sys.exc_info()` (implementation detail); `sys` is now explicitly imported in `server.py`. - 19 new tests in `tests/test_sprint43.py`; 841 tests total (up from 822) ## [v0.50.13] Fix session_search in WebUI sessions — inject SessionDB into AIAgent (PR #356) - **`session_search` now works in WebUI sessions** (`api/streaming.py`): The agent's `session_search` tool returned "Session database not available" for all WebUI sessions. The CLI and gateway code paths both initialize a `SessionDB` instance and pass it via `session_db=` to `AIAgent.__init__()`, but the WebUI streaming path was missing this step. `_run_agent_streaming` now initializes `SessionDB()` before constructing the agent and passes it in. A `try/except` wrapper makes the init non-fatal — if `hermes_state` is unavailable (older installs, test environments), a `WARNING` is printed and `session_db=None` is passed instead, preserving the prior behavior gracefully. - 7 new tests in `tests/test_sprint42.py`; 822 tests total (up from 815) ## [v0.50.12] Profile .env isolation — prevent API key leakage on profile switch (fixes #351) - **API keys no longer leak between profiles on switch** (`api/profiles.py`): `_reload_dotenv()` now tracks which env vars were loaded from the active profile's `.env` and clears them before loading the next profile. Previously, switching from a profile with `OPENAI_API_KEY=X` to a profile without that key left `X` in `os.environ` for the duration of the process — effectively leaking credentials across the profile boundary. A module-level `_loaded_profile_env_keys: set[str]` tracks loaded keys; it is cleared and repopulated on every `_reload_dotenv()` call. - **`apply_onboarding_setup()` ordering fixed** (`api/onboarding.py`): the belt-and-braces `os.environ[key] = api_key` direct assignment is now placed **after** `_reload_dotenv()`. Previously the key was wiped by the isolation cleanup when `_reload_dotenv()` ran immediately after the direct set. - 2 new tests in `tests/test_profile_env_isolation.py`; 815 tests total (up from 813) ## [v0.50.11] Chat table styles + plain URL auto-linking (fixes #341, #342) - **Tables in chat messages now render with visible borders** (`static/style.css`): The `.msg-body` area had no table CSS, so markdown tables sent by the assistant were unstyled and unreadable. Four new rules mirror the existing `.preview-md` table styles: `border-collapse:collapse`, per-cell padding and borders via `var(--border2)`, and an alternating-row tint. Two `:root[data-theme="light"]` overrides ensure the borders and header background adapt correctly in light mode. (fixes #341) - **Plain URLs in chat messages are now clickable** (`static/ui.js`): Bare URLs like `https://example.com` were rendered as plain text. A new autolink pass in `renderMd()` converts `https?://...` URLs to `` tags automatically. Runs after the SAFE_TAGS escape pass (protecting code blocks), before paragraph wrapping. Also applied inside `inlineMd()` so URLs in list items, blockquotes, and table cells are linked too. Trailing punctuation stripped; `esc()` applied to both href and link text. (fixes #342) - 11 new tests (4 in `tests/test_issue341.py`, 7 in `tests/test_issue342.py`); 813 tests total (up from 802) - **Test infrastructure fix** (`tests/test_sprint34.py` #349): two static-file opens used bare relative paths that failed when pytest ran from outside the repo root; replaced with `pathlib.Path(__file__).parent.parent` consistent with the rest of the suite. 813/813 now pass from any working directory. ## [v0.50.10] Title auto-generation fix + mobile close button (PR #333) - **Session title now auto-generates for all default title values** (`'Untitled'`, `'New Chat'`, empty string): The condition in `api/streaming.py` that triggers `title_from()` previously only matched `'Untitled'`. It now also covers `'New Chat'` (used by some external clients/forks) and any empty/falsy title, so sessions started from those states get a proper auto-generated title after the first message. - **Redundant workspace panel close button hidden on mobile** (`static/style.css`): On viewports ≤900px wide, both the desktop collapse button (`#btnCollapseWorkspacePanel`) and the mobile-specific X button (`.mobile-close-btn`) were rendered simultaneously. The desktop button is now hidden on mobile and `.mobile-close-btn` is hidden by default (desktop) and shown only on mobile — eliminating the duplicate control. - 11 new tests in `tests/test_sprint41.py`; 802 tests total (up from 791) ## [v0.50.9] Onboarding works from Docker bridge networks (PR #335, fixes #334) - **Docker users can now complete onboarding without enabling auth first** (closes #334): The onboarding setup endpoint previously only accepted requests from `127.0.0.1`. Docker containers connect via bridge network IPs (`172.17.x.x`, etc.), so the endpoint returned a 403 mid-wizard with no clear explanation. The check now accepts any loopback or RFC-1918 private address (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) using Python's `ipaddress.is_loopback` and `is_private`. Public IPs are still blocked unless auth is enabled. ## [v0.50.8] Model dropdown deduplication — hyphen vs dot separator fix (PR #332) - **Model dropdown no longer shows duplicates for hyphen-format configs** (e.g. `claude-sonnet-4-6` from hermes-agent config): The server-side normalization in `api/config.py` now unifies hyphens and dots when checking whether the default model is already in the dropdown. Previously, `claude-sonnet-4-6` (hermes-agent format) and `claude-sonnet-4.6` (WebUI list format) were treated as different models, causing the same model to appear twice — once as a raw unlabelled entry and once with the correct display name. The raw entry is now suppressed and the labelled one is selected as default. - **README updated**: test count corrected to 791 / 51 files; all module line counts updated to current values; `onboarding.py`, `state_sync.py`, `updates.py` added to the architecture listing. ## [v0.50.7] OAuth provider onboarding path — Codex/Copilot no longer blocks setup (PR #331, fixes #329 bug 2) - **OAuth providers now have a proper onboarding path** (closes bug 2): Users with `openai-codex`, `copilot`, `qwen-oauth`, or any other OAuth-authenticated provider now see a clear confirmation card instead of an unusable API key input form. - If already authenticated (`chat_ready: true`): blue "Provider already authenticated" card with a direct Continue button — no key entry required. - If not yet authenticated: amber card explaining how to run `hermes auth` or `hermes model` in a terminal to complete setup. - Either state includes a collapsible "switch provider" section for users who want to move to an API-key provider instead. - `_build_setup_catalog` now includes `current_is_oauth` boolean; fixed a latent `KeyError` crash when looking up `default_model` for OAuth providers. - 5 new i18n keys in English and Spanish (`onboarding_oauth_*`). - 15 new tests in `tests/test_sprint40.py`; 791 tests total (up from 776) ## [v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3) - **`HERMES_WEBUI_SKIP_ONBOARDING=1`** (closes bug 1): Hosting providers can set this env var to bypass the first-run wizard entirely. Only takes effect when `chat_ready` is also true — a misconfigured deployment still shows the wizard. Accepts `1`, `true`, or `yes`. - **API key takes effect immediately after onboarding** (closes bug 3): `apply_onboarding_setup` now sets `os.environ[env_var]` synchronously after writing the key to `.env`, so the running process can use it without a server restart. Also attempts to reload `hermes_cli`'s config cache as a belt-and-suspenders measure. - 8 new tests in `tests/test_sprint39.py`; 776 tests total (up from 768) ## [v0.50.5] Think-tag stripping with leading whitespace (PR #327) - **Fix think-tag rendering for models that emit leading whitespace** (e.g. MiniMax M2.7): Some models emit one or more newlines before the `` opening tag. The previous regex used a `^` anchor, so it only matched when `` was the very first character. When the anchor failed, the raw `` tag appeared in the rendered message body. - `static/ui.js` (stored messages): removed `^` anchor from `` and Gemma channel-token regexes; switched from `.slice()` to `.replace()` + `.trimStart()` so stripping works regardless of position - `static/messages.js` (live stream): `trimStart()` before `startsWith`/`indexOf` checks; partial-tag-prefix guard also uses trimmed buffer - 10 new tests in `tests/test_sprint38.py`; 768 tests total (up from 758) ## [v0.50.3] Onboarding completes gracefully for pre-configured providers (PR #323, fixes #322) - **OAuth/CLI-configured providers no longer blocked by onboarding** (closes #322): Users with providers already set up via the CLI (`openai-codex`, `copilot`, `nous`, etc.) hit `Unsupported provider for WebUI onboarding` when clicking "Open Hermes" on the finish page. The wizard now marks onboarding complete and lets them through — the agent setup is already done, no wizard steps needed. - 5 new tests in `tests/test_sprint34.py`; 758 tests total (up from 753) ## [v0.50.2] Workspace panel state persists across refreshes - **Workspace panel open/closed persists** (localStorage key `hermes-webui-workspace-panel`): Once you open the workspace/files pane, it stays open after a page refresh. Closing it explicitly saves the closed state, which also survives a refresh. The restore happens in the boot sequence before the first render, so there is no flash of the wrong state. Works for both desktop and mobile. - State is stored as `'open'` or `'closed'` — `'open'` restores as `'browse'` mode; any preview state is re-evaluated normally. - 7 new tests in `tests/test_sprint37.py`; 753 tests total (up from 746) ## [v0.50.1] Mobile Enter key inserts newline (PR #315, fixes #269) - **Enter inserts newline on mobile** (closes #269): On touch-primary devices (detected via `matchMedia('(pointer:coarse)')`), the Enter key now inserts a newline instead of sending. Users send via the Send button, which is always visible on mobile. Desktop behavior is unchanged — Enter sends, Shift+Enter inserts a newline. - The `ctrl+enter` setting continues to work as before on all devices. - Users who explicitly set send key to `enter` on mobile can override in Settings. - 4 new tests in `tests/test_mobile_layout.py`; 746 tests total (up from 742) ## [v0.50.0] Composer-centric UI refresh + Hermes Control Center (PR #242) Major UI overhaul by **[@aronprins](https://github.com/aronprins)** — the biggest single contribution to the project. Rebased and reviewed on `pr-242-review`. - **Composer as control hub** — model selector, profile chip, and workspace chip now live in the composer footer as pill buttons with dropdowns. The context window usage ring (token count, cost, fill) replaces the old linear pill. - **Hermes Control Center** — a single sidebar launcher button (bottom of sidebar) replaces the gear icon settings modal. Tabbed 860px modal: Conversation tab (transcript/JSON export, import, clear), Preferences tab (all settings), System tab (version, password). Always resets to Conversation on close. - **Activity bar removed** — turn-scoped status (thinking, cancelling) renders inline in the composer footer via `setComposerStatus`. - **Session `⋯` dropdown** — per-row pin/archive/duplicate/move/delete actions move from inline buttons into a shared dropdown menu; click-outside/scroll/Escape handling. - **Workspace panel state machine** — `_workspacePanelMode` (`closed`/`browse`/`preview`) in boot.js with proper transitions and discard-unsaved guard. - **Icon additions** — save, chevron-right, arrow-right, pause, paperclip, copy, rotate-ccw, user added to icons.js. - **i18n additions** — 6 new keys across en/de/zh/zh-Hant for control center sections. - **OLED theme** — 7th built-in theme (true black background for OLED displays), originally contributed by **[@kevin-ho](https://github.com/kevin-ho)** in PR #168. - **Mobile fixes** — icon-only composer chips below 640px, `overflow-y: hidden` on `.composer-left` to prevent scrollbar, profile dropdown `max-width: min(260px, calc(100vw - 32px))`. - 742 tests total; all existing tests pass; version badge in System tab updated to v0.50.0. ## [v0.49.4] Cancel stream cleanup guaranteed (PR #309, fixes #299) - **Reliable cancel cleanup** (closes #299): `cancelStream()` no longer depends on the SSE `cancel` event to clear busy state and status text. Previously, if the SSE connection was already closed when cancel fired, "Cancelling..." would linger indefinitely. Now `cancelStream()` clears `S.activeStreamId`, calls `setBusy(false)`, `setStatus('')`, and hides the cancel button directly after the cancel API request — regardless of SSE connection state. The SSE cancel handler still runs when the connection is alive (all operations are idempotent). - 9 new tests in `tests/test_sprint36.py`; 742 tests total (up from 733) ## [v0.49.3] Session title guard + breadcrumb nav + wider panel (PRs #301, #302) - **Preserve user-renamed session titles** (PR #301 by **[@franksong2702](https://github.com/franksong2702)** / closes #300): `title_from()` now only runs when the session title is still `'Untitled'`. Previously it overwrote user-assigned titles on every conversation turn. - Fixed in both `api/streaming.py` (streaming path) and `api/routes.py` (sync path). - **Clickable breadcrumb navigation** (PR #302 by **[@franksong2702](https://github.com/franksong2702)** / closes #292): Workspace file preview now shows a clickable breadcrumb path bar. Each segment navigates directly to that directory level. Paths with spaces and special characters handled correctly. `clearPreview()` restores the directory breadcrumb on close. - **Wider right panel** (PR #302): `PANEL_MAX` raised from 500 to 1200 — right panel can now be dragged wider on ultrawide screens. - **Responsive message width** (PR #302): `.messages-inner` now scales up gracefully at 1400px (1100px max) and 1800px (1200px max) viewport widths instead of capping at 800px on all screen sizes. - 12 new tests in `tests/test_sprint35.py`; 733 tests total (up from 721) ## [v0.49.2] OAuth provider support in onboarding (issues #303, #304) - **OAuth provider bypass** (closes #303, #304): The first-run onboarding wizard now correctly recognizes OAuth-authenticated providers (GitHub Copilot, OpenAI Codex, Nous Portal, Qwen OAuth) as ready, instead of always demanding an API key. - New `_provider_oauth_authenticated()` helper in `api/onboarding.py` checks `hermes_cli.auth.get_auth_status()` first (authoritative), then falls back to parsing `~/.hermes/auth.json` directly for the known OAuth provider IDs (`openai-codex`, `copilot`, `copilot-acp`, `qwen-oauth`, `nous`). - `_status_from_runtime()` now has an `else` branch for providers not in `_SUPPORTED_PROVIDER_SETUPS`; OAuth-authenticated providers return `provider_ready=True` and `setup_state="ready"`. - The `provider_incomplete` status note no longer says "API key" for OAuth providers — it now says "Run 'hermes auth' or 'hermes model' in a terminal to complete setup." - 21 new tests in `tests/test_sprint34.py`; 721 tests total (up from 700) ## [v0.49.1] Docker docs + mobile Profiles button (PRs #291, #265) - **Two-container Docker setup** (PR #291 / closes #288): New `docker-compose.two-container.yml` for running the Hermes Agent and WebUI as separate containers with shared volumes. Documents the architecture clearly; localhost-only port binding by default. - **Mobile Profiles button** (PR #265 by **[@Bobby9228](https://github.com/Bobby9228)**): Adds Profiles to the mobile bottom navigation bar (last position: Chat → Tasks → Skills → Memory → Spaces → Profiles). Uses `mobileSwitchPanel()` for correct active-highlight behaviour; `data-panel="profiles"` attribute set; SVG matches other nav icons; 3 new tests. - 700 tests total (up from 697) ## [v0.49.0] First-run onboarding wizard + self-update hardening (PRs #285, #287, #289) - **One-shot bootstrap and first-run setup wizard** (PR #285 — first-run onboarding flow): New users are greeted with a guided onboarding overlay on first load. The wizard checks system status, configures a provider (OpenRouter, Anthropic, OpenAI, or custom OpenAI-compatible endpoint), sets a workspace and optional password, and marks setup as complete — all without leaving the browser. - `bootstrap.py`: one-shot CLI bootstrap that writes `~/.hermes/config.yaml` and `~/.hermes/.env` from flags; idempotent and safe to re-run - `api/routes.py`: `/api/onboarding/status` (GET) and `/api/onboarding/complete` (POST) endpoints; real provider config persistence to `config.yaml` + `.env` - `static/onboarding.js`: full wizard JS module — step navigation, provider dropdown, model selector, API key input, Back/Continue flow, i18n support - `static/index.html`: onboarding overlay HTML shell + `