DEFAULT_MODEL now defaults to "" instead of "openai/gpt-5.4-mini". Guards added in model-list builder so empty default does not create blank model entries. Adds 3 tests in test_issue646.py. Independent review by @nesquena.
Fixes <|turn|>thinking delimiter (was wrong as <|turn>thinking) in api/streaming.py, static/messages.js, and static/ui.js. Adds 13 regression tests. Independent review by @nesquena.
Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes#627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
Squash-merges PR #614. Fixes Docker 500-on-every-request crash from PermissionError in load_settings() (issue #570 follow-up).
Both SETTINGS_FILE.exists() call sites now catch OSError and fall back to defaults. Reviewer nits addressed: removed unused imports/var in tests, improved log message to say "inaccessible?" instead of "permission denied?". Rebased clean onto v0.50.73. 1373 tests passing, QA harness green.
Squash-merges PR #613. Adds favicon to the app (was missing entirely — blank tab icon). 1371 tests passing, QA harness green. Review by independent agent (see PR comments). Follow-up commit addresses all three reviewer notes: hoisted _STATIC_MIME to module scope, fixed charset=utf-8 being appended to binary MIME types, confirmed correct MIME types on all three favicon formats.
Co-authored-by: tiansiyuan <tiansiyuan@users.noreply.github.com>
Squash-merges PR #611 (@franksong2702). Fixes two edge cases in auto-generated session titles.
1. Strip Markdown labels (`**Session Title:**`, `Title:`) from sanitizer output — these were being persisted verbatim when the LLM emitted them.
2. Skip empty assistant tool-call placeholder messages when extracting the first exchange for title generation — previously the empty row could be latched onto instead of the first real answer.
Also tightens the title prompt to explicitly forbid Markdown, bullets, and label prefixes.
1371 tests passing, QA harness green.
Co-authored-by: Frank Song <franksong2702@gmail.com>
Fixes four bugs + locks in one existing fix with regression tests.
Closes#594 (light theme dialogs), #576 (workspace panel snap), #585 (stale model list after CLI change), #567 (docker-compose macOS UID docs). Confirms and tests #590 (transcribing spinner already present).
Reviewed and approved by @nesquena. 1340 tests passing.
Forwards `api_mode`, `acp_command`, `acp_args`, and `credential_pool` from the resolved runtime provider into `AIAgent.__init__()` in the WebUI streaming path. Fixes Codex account switching and credential pool support for WebUI sessions. Also adds 6 defensive variable initializations to prevent NameError in cleanup paths.
Tests: 1329 passed, 0 skipped. Full TestRuntimeRouteInjection suite passes.
PR by @suinia. Rebased and CHANGELOG added by maintainer.
Co-authored-by: suinia <suinia@users.noreply.github.com>
Fixes two SKIP_ONBOARDING bugs and eliminates 10 permanently-skipped integration tests.
- SKIP_ONBOARDING=1 now honoured unconditionally (no longer gated on chat_ready)
- apply_onboarding_setup refuses to write config/env files when SKIP_ONBOARDING is set
- TestMediaEndpointIntegration (6) and TestOnboardingGateIntegration (4): collection-time
skip guards removed; server reachability checked at runtime with fail() not skip()
Tests: 1327 passed, 0 skipped.
Admin merge — self-built PR, Nathan authorized full merge process in session.
Squash-merges PR #578 (rebased from #574 by @renheqiang + #575 by @nesquena-hermes). MCP server toolsets now included in WebUI sessions; onboarding wizard no longer fires for non-standard providers. 1331 tests pass. Nathan override applied for self-built #575.
When a custom_providers entry in config.yaml has a 'name' field (e.g. 'Agent37'),
the web UI model picker now uses that name as the group header instead of the
generic 'Custom' label.
Previously all custom_providers entries were bucketed under 'custom' which
rendered as 'Custom' in the dropdown optgroup — losing the named identity the
user set up during onboarding.
Changes:
- Track named custom providers as 'custom:<slug>' keys internally so multiple
named providers can coexist as separate groups
- When building model groups, emit each named provider under its own display
name (e.g. 'Agent37') rather than falling through to the generic label
- Unnamed entries (no 'name' field) still fall back to the 'Custom' group
- When all entries are named, the bare 'Custom' bucket is suppressed
Adds 7 tests covering single named provider, multiple named providers,
multiple models in same named provider, unnamed fallback, and mixed cases.
Fixes#557
BUG-1 (CRITICAL): messages.js line 522 — mismatched quote in
setComposerStatus('Reconnecting…') caused JS syntax error on the
reconnect path.
BUG-2 (HIGH): messages.js line 491 — broken template literal
'\\n\\n*{d.hint}*' restored to '\n\n*${d.hint}*'. Error hint
text was non-functional (missing $ prefix and escaped newlines).
BUG-3 (HIGH): messages.js — showApprovalCard(pending, pendingCount),
_approvalCurrentId, and approval_id in respondApproval() were removed,
regressing the simultaneous approval queue fix from PR #546. Restored
all three, including the '1 of N pending' counter and poll passthrough.
BUG-4 (LOW): api/streaming.py — MiniMax thinking delimiter regex
missing closing pipe: <|channel> -> <|channel|> in both
_strip_thinking_markup() and _looks_invalid_generated_title().
ALSO: test_issue487b.py docstring changed to raw string to fix
DeprecationWarning for invalid escape sequence '\s'.
- Remove llama-4-scout and llama-4-maverick
- Add qwen/qwen3-coder, qwen/qwen3.6-plus, x-ai/grok-4-20
- Add qwen and x-ai to _PROVIDER_MODELS and _PROVIDER_DISPLAY
The original fix preserved full IDs only when config_provider == 'custom',
which broke existing tests expecting prefix-stripping for known namespaces
like 'openai/' and 'google/'.
The correct heuristic: strip the prefix only when it is a known provider
namespace (i.e. prefix in _PROVIDER_MODELS — 'openai', 'google', 'anthropic',
etc.). Unknown prefixes like 'zai-org' are intrinsic to the model ID and must
be preserved. This satisfies both the DeepInfra use case (#548) and the
existing #433 regression tests.
Changes _pending from a single overwriting dict value to a list,
so parallel tool calls each get their own approval slot.
api/routes.py:
- Wraps submit_pending() to append to a list and assign a stable
approval_id (uuid4) to each entry.
- _handle_approval_pending() returns the first queued entry plus
pending_count so the UI can show '1 of N'.
- _handle_approval_respond() pops by approval_id (falls back to
oldest entry for backward-compat with old clients).
- Backward-compat: legacy single-dict values in _pending are
handled without crashing.
static/messages.js:
- respondApproval() sends approval_id in the POST body.
- showApprovalCard() accepts pendingCount, shows '1 of N pending'
counter when multiple approvals are queued.
- _approvalCurrentId tracks the approval_id of the displayed card.
- Poll loop passes pending_count to showApprovalCard.
static/index.html:
- Adds approvalCounter element for the '1 of N' display.
tests/test_approval_queue.py:
- 14 tests: static-analysis checks (Python + JS + HTML),
functional tests that inject two simultaneous approvals and
verify both are surfaced and independently resolvable.
Extends _sanitize_messages_for_api() with a two-pass approach:
1. Collect all tool_call_ids declared in assistant messages (handles
both OpenAI 'id' and Anthropic 'call_id' field names).
2. Drop any tool-role messages whose tool_call_id was not declared
by a preceding assistant message.
Strictly-conformant providers (Mercury-2/Inception, newer OpenAI
models) reject histories with orphaned tool results with a 400 error:
'Message has tool role, but there was no previous assistant message
with a tool call.' This can happen when histories are edited, when
switching between providers, or when partial messages are stored.
Adds 13 regression tests covering: valid roundtrip preservation,
multiple tool calls, partial orphan filtering, Anthropic call_id,
edge cases (None tool_calls, missing tool_call_id, non-dict entries).
When a user has custom_providers configured in config.yaml, their custom
models should appear in the model picker even if active_provider is set
to a different provider (e.g. openrouter). Previously, the custom provider
was always discarded from detected_providers when active_provider != 'custom',
making custom models invisible.
Fix: only discard 'custom' if there are no custom_providers entries.
Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
Co-authored-by: shruggr <shruggr@users.noreply.github.com>
Removes /root from _BLOCKED_SYSTEM_ROOTS in api/workspace.py, allowing
Hermes running as root (e.g. Docker, VPS) to use /root as a workspace
without a 'system directory' rejection.
Fixes a fragile string split in api/streaming.py: base_text extraction
now guards against msg_text that contains no '[Attached files:' marker,
preventing the split from producing empty-string on those messages.
Fixes: #510, partial fix from #521 (workspace + split guard only).
Co-authored-by: ccqqlo <ccqqlo@users.noreply.github.com>
* fix: workspace list cleaner — allow own-profile paths, remove brittle string filter
Two bugs in _clean_workspace_list() caused workspace adds to silently vanish
on the next load, making the duplicate-check test and workspace rename test fail:
1. Brittle string filter: 'if test-workspace in path or webui-mvp-test in path:
continue' — removed. The test server's workspace IS under these paths, so any
workspace added during testing got silently dropped on the next load_workspaces()
call. The p.is_dir() check already handles non-existent paths.
2. Cross-profile filter too broad: 'if p is under ~/.hermes/profiles/: skip' —
this correctly blocked cross-profile leakage but also blocked the current
profile's own paths (e.g. ~/.hermes/profiles/webui/webui-mvp-test/...).
Fixed: only skip if the path is under profiles/ AND under a DIFFERENT profile's
directory. Paths under the current profile's own home are kept.
* docs: v0.50.36 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: relax workspace trust boundary to user home directory
The previous restriction required workspaces to be under DEFAULT_WORKSPACE
(/home/hermes/workspace), which blocked all profile-specific workspaces
(~/CodePath, ~/General, ~/WebUI, ~/Camanji, etc.) since each profile uses
a different directory under home.
New boundary: any directory under Path.home() is trusted.
This still blocks /etc, /tmp, /var, /root, /usr and all paths outside the
user's home, while allowing any legitimate workspace under ~/
Also updates test assertions from 'trusted workspace root' to 'outside'
since the new error message says 'outside the user home directory'.
* fix: workspace trust uses home-dir + saved-list, not single ancestor
Three-layer trust model that works cross-platform and multi-workspace:
1. BLOCKLIST: /etc, /usr, /var, /bin, /sbin, /boot, /proc, /sys, /dev, /root,
/lib, /lib64, /opt/homebrew — always rejected, even if somehow saved
2. HOME CHECK: any path under Path.home() is trusted — covers ~/CodePath,
~/hermes-webui-public, ~/WebUI, ~/General, ~/Camanji simultaneously;
Path.home() is cross-platform (Linux ~/..., macOS ~/..., Windows C:\Users\...\...)
3. SAVED LIST ESCAPE HATCH: if a path is already in the saved workspace list,
it's trusted regardless of location — covers self-hosted deployments where
workspaces live outside home (/data/projects, /opt/workspace, etc.)
None/empty → DEFAULT_WORKSPACE (always trusted, validated at startup)
* docs: v0.50.35 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix(workspace): restrict session workspaces to trusted roots
* fix: use boot-time DEFAULT_WORKSPACE instead of profile default for trusted workspace root
_profile_default_workspace() reads the agent's terminal.cwd which may differ
from the WebUI's configured workspace root. Use _BOOT_DEFAULT_WORKSPACE (which
respects HERMES_WEBUI_DEFAULT_WORKSPACE for test isolation) to stay consistent
with how new_session() seeds the initial workspace.
* docs: v0.50.34 release — version badge and CHANGELOG
---------
Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: delegate all live model fetching to agent's provider_model_ids()
Previously _handle_live_models() maintained its own per-provider logic:
- anthropic, google, gemini returned 'not_supported' (hardcoded exclusions)
- openai-codex had a custom branch (added in v0.50.30)
- openai/copilot had hardcoded base URLs
- other providers fell through to a generic /v1/models fetch
Now the handler delegates entirely to hermes_cli.models.provider_model_ids(),
which is the agent's authoritative resolver:
- anthropic: live fetch via /v1/models with correct API-key or OAuth headers
- copilot: live fetch from api.githubcopilot.com/models with Copilot headers
- openai-codex: Codex OAuth endpoint + ~/.codex/ cache fallback
- nous: live fetch from Nous inference portal
- deepseek, kimi-coding: generic OpenAI-compat /v1/models
- opencode-zen/go: OpenCode live catalog
- openrouter: curated static list (live returns 300+ which is overwhelming)
- google/gemini, zai, minimax: static list (non-standard or Anthropic-compat endpoints)
- any others: graceful static fallback
Also removed the client-side skip guard in _fetchLiveModels() (ui.js) that
blocked live fetching for anthropic, google, and gemini.
The hardcoded model lists in _PROVIDER_MODELS remain as the fallback when
credentials are missing or network is unavailable — they are never shown
when live data is available.
* docs: v0.50.31 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: route openai-codex live model fetch through agent's get_codex_model_ids()
Previously _handle_live_models() grouped openai-codex with openai and sent a
request to 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 and the UI showed only the hardcoded static list.
Now: openai-codex has a dedicated early-exit branch that calls
hermes_cli.codex_models.get_codex_model_ids() — the same path the agent CLI
uses. It resolves models in order: live Codex API (if OAuth token available) >
~/.codex/ local cache > DEFAULT_CODEX_MODELS. This means:
- If the user has a valid Codex OAuth session, the UI gets the exact model list
their subscription provides (e.g. gpt-5.2, gpt-5.3-codex-spark that aren't
in the hardcoded list)
- If the OAuth session is expired, falls back to local ~/.codex/ cache
- Always has DEFAULT_CODEX_MODELS as final fallback
Also: improved label generation for Codex model IDs (GPT-5.4 Mini vs GPT 5 4 Mini).
Added 1 structural regression test.
* docs: v0.50.30 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: expand openai-codex model catalog to match agent DEFAULT_CODEX_MODELS
The _PROVIDER_MODELS["openai-codex"] catalog only listed codex-mini-latest,
so the model dropdown for profiles using openai-codex provider (e.g. CodePath)
showed only that one entry — even when the profile's saved default_model was
gpt-5.4 or another standard Codex model.
Updated to match DEFAULT_CODEX_MODELS from hermes_cli/codex_models.py:
- 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 (kept, relabeled as 'Codex Mini (latest)')
Also adds 2 regression tests: catalog includes gpt-5.4, display name correct.
* docs: v0.50.28 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); 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_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[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() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* feat(ui): opt-in chat bubble layout
Closes#336.
Adds a settings toggle that right-aligns user messages and left-aligns
assistant replies. Off by default - the current full-width layout is
friendlier to code blocks and tool output, so bubbles are strictly
opt-in per the maintainer note on the issue.
Wiring follows the existing token-usage / cli-sessions pattern:
- api/config.py: new bubble_layout bool in _SETTINGS_DEFAULTS and
_SETTINGS_BOOL_KEYS, validated + persisted like the rest.
- static/style.css: .bubble-layout gated selectors using :has() to
tag msg-rows by .msg-role.user / .msg-role.assistant without any JS
changes to message creation. User rows get align-self: flex-end,
max-width: 75%, and a row-reverse header; assistant rows flex-start.
A 700px media query widens the max to 92% on narrow screens.
- static/index.html: new checkbox with i18n keys next to the existing
token-usage toggle.
- static/panels.js: loads the setting into the checkbox, saves it
back, and toggles body.bubble-layout immediately on save.
- static/boot.js: applies the class on initial load so refreshed
tabs honor the persisted setting without a flash.
- static/i18n.js: English label + description.
Test suite errors are environmental (test server fails to start on
port 8788 on main as well).
* i18n(es): add Spanish translations for bubble_layout setting
* fix+test: boot.js bubble-layout reset on failure; add 22 tests for issue #336
* docs: v0.50.24 release — version badge and CHANGELOG
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>