Commit Graph

685 Commits

Author SHA1 Message Date
Chris Watson 8566462b72 feat: add MEDIA_ALLOWED_ROOTS env var for configurable /api/media whitelist
The /api/media endpoint only serves files from ~/.hermes, /tmp, and the
active workspace. Power users with media in custom directories (models,
Downloads, Pictures, ComfyUI outputs) have no way to serve those files
inline without copying or symlinking.

Add MEDIA_ALLOWED_ROOTS env var — a colon-separated list of absolute
paths — that extends the allowed roots at runtime. Each entry is resolved
and validated as an existing directory before being appended. Non-existent
or invalid paths are silently skipped.

This is purely additive: the built-in security whitelist is unchanged,
and if MEDIA_ALLOWED_ROOTS is unset, behavior is identical to before.
2026-05-11 02:45:46 +00:00
nesquena-hermes 9f3f8ea902 fix(recovery): close concurrency hazards in state.db sidecar reconciliation
Two concrete data-corruption vectors flagged in Opus review of PR #2041,
both fixed atomically so the new repair-safe endpoint is safe for production:

1. Shared tmp filename under concurrent calls
   `tmp = target.with_suffix('.json.reconcile.tmp')` produced a fixed path
   per session ID. Two simultaneous repair-safe POSTs would interleave bytes
   in the same tmp file, then both rename → corrupted JSON. Now matches the
   `Session.save()` convention at api/models.py:484 with a pid+tid suffix.

2. TOCTOU between target.exists() check and tmp.replace(target)
   `os.replace()` overwrites unconditionally. If a concurrent Session.save()
   for the same SID materialized the live sidecar in the microsecond window
   between the existence check and the rename, the reconciliation would
   silently overwrite a live sidecar with a (lossier) state.db reconstruction.
   Switched to `os.link()` + `unlink(tmp)` which is atomic create-or-fail —
   on FileExistsError we record `skipped: sidecar_appeared_during_reconcile`
   and keep the live sidecar untouched.

Plus a round-trip schema-parity test: materialize a sidecar from state.db,
then load it back through `Session.load()` and assert the messages survive.
Catches future schema drift between `_state_db_row_to_sidecar()` and
`Session.__init__()`. Also adds a guard test confirming the .reconcile.tmp
suffix includes pid+tid (regression guard for hazard #1).

Tests: 23 passing across the recovery suite (was 21; +2 new in this commit).

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
2026-05-11 02:44:38 +00:00
ai-ag2026 a34ded8e99 feat: reconcile missing WebUI sidecars from state db 2026-05-11 02:43:00 +00:00
ai-ag2026 90c3611732 feat: expose session recovery audit and safe repair endpoints 2026-05-11 02:43:00 +00:00
nesquena-hermes db32b70771 Merge PR #2038 into stage-335
# Conflicts:
#	CHANGELOG.md
2026-05-11 00:25:35 +00:00
nesquena-hermes 97b283c5a4 Merge PR #2039 into stage-335 2026-05-11 00:25:07 +00:00
nesquena-hermes 91f1e3df94 Merge PR #2036 into stage-335 2026-05-11 00:25:06 +00:00
ai-ag2026 2ead7daa2f fix: expose active run lifecycle in health 2026-05-11 02:15:00 +02:00
Frank Song 642249747f Fix session message identity dedup 2026-05-11 08:14:50 +08:00
ai-ag2026 7b6d91d490 feat: add read-only session recovery audit 2026-05-11 02:06:43 +02:00
ai-ag2026 663817570c fix: recover orphaned session backups on startup 2026-05-11 02:03:37 +02:00
nesquena-hermes 9c471be4a3 Merge PR #2034 into stage-334
# Conflicts:
#	CHANGELOG.md
2026-05-10 23:38:05 +00:00
nesquena-hermes 02506eadb5 Merge PR #2032 into stage-334 2026-05-10 23:37:39 +00:00
Frank Song 128e734df4 Fix Xiaomi API key env detection 2026-05-11 07:33:52 +08:00
Michael Lam d620f4394a fix: prewarm skill imports outside env lock 2026-05-10 15:51:49 -07:00
Michael Lam cb3284b73f fix: harden quota probe subprocess handling 2026-05-10 12:18:02 -07:00
nesquena-hermes 2377216860 Stage 333: PR #2009 — feat(context): live status tracking during streaming by @dobby-d-elf 2026-05-10 18:16:59 +00:00
nesquena-hermes 8824f3c88d Stage 333: PR #2022 — fix(resolver): prefer active provider for default model overlap by @Michaelyklam 2026-05-10 18:16:59 +00:00
nesquena-hermes 22991fa820 Merge remote-tracking branch 'origin/master' into stage-331
# Conflicts:
#	CHANGELOG.md
2026-05-10 18:03:55 +00:00
Michael Lam ed183784d4 fix: prefer active provider for default model overlap 2026-05-10 10:49:12 -07:00
nesquena-hermes c624770c63 Stage 331: PR #2015 — fix(sessions): stitch continued session transcripts by @Jellypowered 2026-05-10 17:09:21 +00:00
nesquena-hermes 44dc7d05e8 Stage 331: PR #2014 — fix(sessions): keep explicit fork sessions out of compression lineage by @ai-ag2026 2026-05-10 17:09:21 +00:00
nesquena-hermes b68d7c62e7 Stage 331: PR #2012 — feat(sessions): read-only session lineage report endpoint by @dso2ng 2026-05-10 17:09:21 +00:00
nesquena-hermes c156e5a256 Stage 331: PR #2006 — fix(compression): stamp profile on continuation session by @qxxaa 2026-05-10 17:09:21 +00:00
nesquena-hermes a897ccfd9c Stage 330: PR #2005 — feat(provider): add Xiaomi MiMo provider support by @vikarag 2026-05-10 17:08:46 +00:00
nesquena-hermes 9060bdb344 Stage 330: PR #2001 — fix(clarify): honor clarify.timeout config by @franksong2702 2026-05-10 17:07:37 +00:00
nesquena-hermes 7eced19463 Stage 330: PR #2000 — fix(skills): patch module-level caches on per-request profile switch by @qxxaa 2026-05-10 17:07:37 +00:00
dobby-d-elf fecfc5f6db fix: reanchor live context usage updates 2026-05-10 10:31:14 -06:00
Jellypowered 8aed650b4c Stitch continued session transcripts in WebUI 2026-05-10 11:10:54 -05:00
ai-ag2026 017a631b6c fix: keep explicit fork sessions out of compression lineage 2026-05-10 18:03:21 +02:00
Dennis Soong c3cf8b10e9 feat: add read-only session lineage report 2026-05-10 23:28:14 +08:00
dobby-d-elf 56d68b7511 fix: keep live context metering session-scoped 2026-05-10 08:20:37 -06:00
dobby-d-elf 1cf0ff01b5 feat: live context window status tracking during streaming 2026-05-10 06:51:46 -06:00
qxxaa f665e50738 fix: stamp profile on continuation session after context compression
When context compression fires, the agent rotates to a new session_id.
The compression migration block correctly migrates the session lock,
SESSION_AGENT_CACHE, SESSIONS dict, and the session file rename, but
does not ensure s.profile is set on the continuation session.

On the next request, _run_agent_streaming resolves the profile via:

    get_hermes_home_for_profile(getattr(s, 'profile', None))

With s.profile == None this falls back to the default profile's
HERMES_HOME. Memory tool calls then read and write the wrong profile's
MEMORY.md — confirmed by investigation: session 0dfefb (continuation
after compression from a troubleshooting profile session) read memory
at 16% / 1,184 chars with 4 entries, while the troubleshooting profile's
actual state was 72-77% / 5,000+ chars. That reading could only come
from the default profile's bank. Subsequent replace operations failed
because the target entries existed only in the troubleshooting profile.

There are two failure paths:

1. In-memory: if s.profile was None from the start (legacy session or
   one created before this fix), the continuation session object carries
   null through the current request.

2. Persistence: s.save() persists "profile": null to the continuation
   session's JSON file (profile is in METADATA_FIELDS, models.py ~408).
   On the next request, Session.load(new_sid) reads it back as null and
   get_hermes_home_for_profile(None) falls back to the default profile.

Fix: capture _resolved_profile_name at request entry (~line 2019),
immediately after profile home resolution. This is the only point where
profile context is reliable: s.profile if already set, otherwise
get_active_profile_name() — which at that point reads thread-local
storage (_tls.profile) correctly set by the HTTP handler thread via
set_request_profile(). Calling get_active_profile_name() at compression
time instead would be unsafe: the streaming thread is a separate
threading.Thread, does not inherit TLS, and the call would fall back to
the process-global _active_profile which may belong to a different
concurrent tab.

Stamp s.profile in the compression migration block immediately after
s.session_id = new_sid. Guarded by `if not s.profile` so sessions that
already have a profile set are unaffected. A logger.info line records
when the stamp fires, making future investigation straightforward.

Fixes: memory writes bleeding into default profile after compression
Reproduces: reliably on any long non-default profile session that hits
the compression threshold (default: 0.80 context fill)
2026-05-10 09:57:45 +01:00
vikarag 84a172b572 feat: add Xiaomi MiMo provider support
Add xiaomi to _PROVIDER_DISPLAY, _PROVIDER_MODELS, and _PROVIDER_ALIASES
so the WebUI recognizes Xiaomi as a first-class provider.

Models included:
- mimo-v2.5-pro (MiMo V2.5 Pro)
- mimo-v2.5 (MiMo V2.5)
- mimo-v2-pro (MiMo V2 Pro)
- mimo-v2-omni (MiMo V2 Omni)
- mimo-v2-flash (MiMo V2 Flash)

Aliases: mimo, xiaomi-mimo -> xiaomi

The hermes-agent CLI already registers xiaomi as a provider
(hermes_cli/models.py, hermes_cli/auth.py) but the WebUI was missing
the corresponding entries, causing the model dropdown to fall back to
OpenRouter and the provider list to show 'Unsupported'.
2026-05-10 17:48:37 +09:00
Frank Song 1bec8070f2 fix(1833): persist compression anchor summary for reload UI 2026-05-10 16:45:16 +08:00
Frank Song 2e6b3601bd fix(clarify): honor clarify.timeout config in webui prompts 2026-05-10 16:05:50 +08:00
qxxaa 7ee41c9b12 fix: patch skills module-level caches on per-request profile switch
Per-request profile switches (process_wide=False, introduced in #1700)
update os.environ['HERMES_HOME'] but skip _set_hermes_home(), which is
responsible for monkeypatching module-level caches.

Both tools/skills_tool.py and tools/skill_manager_tool.py set
HERMES_HOME and SKILLS_DIR once at import time. When a non-default
profile is active in the WebUI, os.environ['HERMES_HOME'] is correctly
updated per-turn in the _ENV_LOCK block, but the module-level
constants still point at the root profile. All agent-side skill
operations — skills_list(), skill_view(), skill_manage() — read and
write to the wrong directory.

Add the same monkeypatching that _set_hermes_home() already performs
(profiles.py line ~620) to the per-turn env setup block in
streaming.py, covering both skills_tool and skill_manager_tool.

The WebUI display half was already fixed in #1917 via
_active_skills_dir() in routes.py. This patch fixes the agent-side
half so the running agent resolves skills from the correct profile.
2026-05-10 09:02:49 +01:00
Frank Song 1e1a9481b4 fix(i18n): localize /goal runtime status strings 2026-05-10 15:21:24 +08:00
nesquena-hermes a3af4a3c8f fix(profile/mcp): discover MCP tools after per-session HERMES_HOME mutation
Issue #1968: switching to a non-default profile in the WebUI dropdown
had no effect on which MCP servers were available. Every chat session,
regardless of profile, only saw the default profile's mcp_servers from
~/.hermes/config.yaml. Non-default profile MCP servers (postgres, custom
stdio servers, anything in <profile>/config.yaml) never registered.

Root cause: api/streaming.py:1922 called discover_mcp_tools() at the
TOP of _run_agent_streaming(), about 100 lines BEFORE the per-session
'os.environ["HERMES_HOME"] = _profile_home' mutation at line 2053.
discover_mcp_tools() reads ~/.hermes/config.yaml via get_hermes_home(),
which uses os.environ['HERMES_HOME']. So at the call site, HERMES_HOME
was still whatever the WebUI server process had at startup — the default
profile, every time.

Fix: relocate the discover_mcp_tools() call past the _ENV_LOCK block so
get_hermes_home() resolves to the session's actual profile home. Same
try/except wrapping is preserved; same idempotency semantics on
already-connected servers; same lazy-import pattern.

Caveat (out of scope, agent-side): _servers in tools/mcp_tool.py is a
process-global Dict[str, MCPServerTask] keyed only by server name. So
once profile A registers a server named e.g. 'postgres', profile B's
discovery sees 'postgres' as already connected and skips it — even if
B's config points at a different binary or DB. Concurrent multi-profile
WebUI processes will still hit 'first profile wins per server name'.
Fully fixing that requires keying _servers by (profile_home, name)
upstream in hermes-agent. This PR ships layer 1 only — fixes the
single-non-default-profile case (the headline symptom).

Tests: tests/test_issue1968_mcp_profile_discovery.py — 4 static tests
pinning the lexical ordering invariants. Verified mutation-safety: a
proof-of-concept revert (re-adding a discover call before the
HERMES_HOME mutation) makes the 'only called once' test fail.

Test suite: 5047 passed, 4 skipped, 3 xpassed, 0 regressions.

Closes #1968
2026-05-09 20:08:16 +00:00
nesquena-hermes 8782fd2675 fix(stage-326): apply Opus advisor critical + recommended fixes
CRITICAL: #1951 PENDING_GOAL_CONTINUATION race
  Removes `PENDING_GOAL_CONTINUATION.discard(session_id)` from the
  streaming worker's `finally` cleanup block. The marker is set inside
  the SAME function call (line ~3328 on `goal_continue`) and the discard
  in the `finally` (line ~3553) almost always raced ahead of the
  frontend's SSE-receive → POST /api/chat/start round-trip, erasing
  the marker before the consumer in routes.py could read it. The
  consumer (`_start_chat_stream_for_session` in routes.py:6522) already
  discards atomically when consuming, so removing the streaming-side
  discard preserves single-use semantics and unblocks the
  goal-continuation chain.

  Adds tests/test_stage326_pending_goal_continuation_race.py with 5
  regression guards:
  1. streaming.py's finally must NOT discard PENDING_GOAL_CONTINUATION
  2. routes.py consumer must check + set + discard atomically
  3. PENDING_GOAL_CONTINUATION must be a set (GIL-safe single-op)
  4. STREAM_GOAL_RELATED.pop must be keyed by stream_id, not session_id
  5. PENDING_GOAL_CONTINUATION.add must precede the goal_continue SSE
     emission in source ordering

HARDENING: #1956 composer-draft input validation
  Per Opus, the POST /api/session/draft handler accepted unbounded /
  arbitrary-typed text and files inputs. With the 400ms debounced
  auto-save firing on every keystroke, a misbehaving client could
  persist multi-MB strings into the session JSON. Adds:
  - text: coerced to str if not already; clamped to 50_000 chars
  - files: coerced to list if not already; clamped to 50 entries
  Validation runs BEFORE the session lock acquire / save.

  Adds tests/test_stage326_composer_draft_validation.py with 5 guards.

Verdict from Opus advisor on stage-326: SHIP-WITH-FIXES.
This commit applies the required + recommended fixes; #1957 hardening
fixed in a prior stage commit.
2026-05-09 18:36:01 +00:00
nesquena-hermes 404e24ac9d fix(stage-326): preserve SESSION_TTL constant + reconcile #1957 tests
PR #1957 deleted the SESSION_TTL = 86400 * 30 module-level constant in
favor of the new _resolve_session_ttl() helper. Two existing regression
tests pin the constant: test_auth_sessions.TestSessionPruning.test_session_ttl_is_24_hours
imports SESSION_TTL directly, and test_v050258_opus_followups.test_redirect_session_ttl_30_days
asserts the literal "SESSION_TTL = 86400 * 30" line is present in source
(guarding against the daily-kick-out regression from #1419).

Restore SESSION_TTL as the named fallback for _resolve_session_ttl(); the
new env-var/settings.json path is unchanged. Backwards-compatible.

Also fix the new TestSessionTtlResolution suite:
- Switch from pytest's `monkeypatch` fixture (incompatible with
  unittest.TestCase subclasses) to setUp/tearDown env snapshotting
- Reconcile clamp tests with actual implementation: out-of-range env
  values fall through to settings/default, not snap to bounds
- test_session_uses_dynamic_ttl now sets the env var so the dynamic
  resolved value (3600s) is exercised rather than expecting the default

Verified: tests/test_auth_sessions.py + tests/test_v050258_opus_followups.py
21/21 pass.
2026-05-09 18:33:28 +00:00
nesquena-hermes 7cf8dcff4c Stage 326: PR #1956 — feat: persistent composer draft — server-side, cross-client, survives refresh by @JKJameson 2026-05-09 18:17:51 +00:00
nesquena-hermes 4751b5ace5 Stage 326: PR #1951 — fix: only evaluate goal hook on goal-related turns (#1932) by @amlyczz 2026-05-09 18:17:20 +00:00
nesquena-hermes 22ea145d49 Stage 326: PR #1950 — Mute stale stopped gateway heartbeat by @franksong2702 2026-05-09 18:16:16 +00:00
nesquena-hermes c2f0c6ccc0 Stage 326: PR #1961 — fix: WebUI respects image_input_mode — stop unconditionally embedding native images by @sbe27 2026-05-09 18:16:16 +00:00
nesquena-hermes 072ec41e0a Stage 326: PR #1947 — fix: show same model from different custom providers instead of deduplicating by @happy5318 2026-05-09 18:16:16 +00:00
nesquena-hermes 1c84da07fc Stage 326: PR #1953 — fix(config): skip #1776 provider peel for custom host:port slugs by @lucky-yonug 2026-05-09 18:16:16 +00:00
hermes-agent b443e8ea5a fix: WebUI respects image_input_mode — stop unconditionally embedding native images
_build_native_multimodal_message() unconditionally embedded images as
native image_url parts, bypassing the agent's image_input_mode config.

Add _resolve_image_input_mode(cfg) helper mirroring the agent's
decide_image_input_mode logic, and wire it into
_build_native_multimodal_message with a new cfg parameter.

When mode resolves to 'text' (explicit aux vision config, or
image_input_mode: text), returns plain string so the agent's
existing text-mode pipeline (vision_analyze) handles images.

Closes #1959
2026-05-09 19:39:50 +02:00
hermes-gimmethebeans 9d7c213971 feat(auth): make session TTL configurable via env var and settings.json
Add _resolve_session_ttl() with three-layer precedence:
  1. HERMES_WEBUI_SESSION_TTL env var (highest priority)
  2. session_ttl_seconds in settings.json
  3. Default: 86400 * 30 (30 days)

Clamped to [60s, 1 year] for safety. Settings changes take effect
immediately since the function is called dynamically at each login/cookie-write.

Closes #1954
2026-05-09 17:11:53 +00:00