Prior round stripped provider prefixes from both sides and matched bare-only,
which over-matched: openai/gpt-4o would match default openrouter/gpt-4o. Now the
matcher compares bare model ids AND rejects when both sides identify DIFFERENT
providers (from provider/ prefix, @provider: qualifier, or the explicit provider
arg). Same-provider / unknown-session-provider still match. Added cross-provider
rejection regression tests.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
The default-only context_length guard compared model.default to the session
model with exact string equality. But model.default and the session model can
be stored in equivalent-but-different shapes (bare 'claude-opus-4.8',
provider-prefixed 'anthropic/claude-opus-4.8', or '@anthropic:claude-opus-4.8').
An exact compare wrongly treats the actual default model as non-default and
drops its configured context_length cap for provider-prefixed configs.
Add api/routes._model_matches_configured_default(session_model, cfg_default,
provider) that normalizes all three shapes, and use it at all 6 guard sites
(routes resolver + the 5 api/streaming.py sites: live-usage snapshot, persistence
_skip_cc_cl, persistence fallback _apply_cfg_ctx, SSE-done _dropped_stale_cap_sse,
SSE fallback _apply_cfg_ctx). Imported function-scoped in streaming to avoid the
routes<->streaming module-level circular import. 10 helper unit tests + a
behavioral test that a prefixed default still receives its cap.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
The threshold-rescale block runs unconditionally after the fallback and
references _skip_cc_cl/_cc_cl, which were only defined inside 'if _cc_for_save:'.
On the no-compressor path (fresh agent / interrupted stream) that raised
UnboundLocalError (caught by test_issue1857_usage_overwrite). Hoist both inits
above the block (no-op rescale when no compressor). Also widen the #1318
source-assertion test to accept the widened fallback gate (still asserts the
falsy-check invariant).
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Codex regression gate (+Opus, both independently) found the default-only guard
dropped the stale compressor cap but two sibling paths stayed inconsistent:
1. Per-turn persistence: fallback resolver only ran when context_length was
falsy, so a previously-persisted stale 232K survived forever on non-default
sessions. Now also runs when _skip_cc_cl, and rescales threshold_tokens to
the recomputed real cap (or clears it).
2. Terminal SSE 'done' payload: re-emitted the stale compressor threshold, so
messages.js overwrote S.lastUsage and the indicator reverted on stream end.
Now rescales threshold to the resolved window when the stale cap was dropped.
Added 3 source-structure regression tests pinning both fixes; bumped the brittle
test_pr1341 distance limit 13000→15000 (+ noted it should become structural).
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
The default-only guard corrected context_length to the real per-model cap
(e.g. 1M for claude-opus-4.7-1m) but left threshold_tokens pointing at the
ContextCompressor's stale value (computed from the global 232K cap → 197.2k
@ 85%). UI then showed 'auto-compress at 197.2k / 1M' which is misleading.
Rescale threshold_tokens by the real/orig ratio so the displayed trigger
reflects the actual window (e.g. ~850k @ 1M).
NOTE: this only corrects the SSE display payload. The real auto-compress
trigger lives inside ContextCompressor in hermes-agent (agent_init.py:1446
constructs it with the global cap). A full fix requires a parallel change
upstream — tracked separately.
_live_usage_snapshot() runs on every metering tick (~10x/sec while streaming).
The #3256 default-only guard recomputed get_model_context_length() there on
every tick for non-default models, which does a config read + potential
metadata/network probe — freezing claude-opus-4.7-1m streams while the default
model (4.8) stayed fast (guard not triggered for it). Resolve the real cap at
most once per stream via _real_ctx_cache. Backend-only, no frontend changes.
Re-applies cb0065eb + b34311b3 (context_length default-only guard) which were
dropped by the upgrade reset to v0.51.185. Fixes 4.7-1m context window showing
as stale global cap (232K) instead of real 1M metadata. Backend-only: touches
api/routes.py + api/streaming.py, zero frontend/render changes.
Both pre-release reviewers (Opus advisor + Codex regression gate) converged on
the same MUST-FIX:
- #3194: treating gateway_stale_stopped_state as 'configured' contradicted
#1944 (a stopped root gateway should read like 'not configured' so the
banner doesn't nag). Now ONLY stale-RUNNING metadata
(reason=gateway_stale_running_state or gateway_state=='running') flips
configured=True; stale-stopped falls through to bool(identity_map) like the
genuinely-unconfigured case. Updated the test accordingly + added a
stale-stopped no-regression test.
Opus follow-ups also applied:
- #2905: narrowed the populated-home markers to WebUI-only artifacts
(webui/, webui/sessions, webui/settings.json), dropping config.yaml/auth.json
so a long-time agent user installing WebUI fresh isn't wrongly diverted to
the legacy %USERPROFILE%\.hermes (auth.json predates #2897 there).
- profiles._resolve_base_hermes_home(): narrowed except Exception -> ImportError
so a real bug in the config helper still surfaces.
Adjacent suites green: #2840, #1879, gateway_status_agent_health (66 tests).
Two urgent breaking bugs that had no PR, combined into one hotfix.
#2905 (data-loss-class): v0.51.134 moved the Windows default Hermes home
from %USERPROFILE%\.hermes to %LOCALAPPDATA%\hermes (PR #2897) with no
migration, so upgrading Windows users opened the app to empty
sessions/pins/settings (data intact on disk, at an address the new build
no longer read). _platform_default_hermes_home() now prefers the populated
legacy home ONLY when the new location is not yet established —
non-destructive (no file moves) and self-healing on next launch.
profiles._resolve_base_hermes_home() delegates to the same config helper so
the active-profile pointer can never drift from STATE_DIR.
#3194: GET /api/gateway/status reported 'Gateway not configured' on a fresh
two-container Docker deploy because an alive=None + gateway_stale_running_state
health payload with an empty identity_map fell through to
configured=bool(identity_map)=False. The alive=None branch now treats a
payload carrying gateway metadata (gateway_state detail, or a stale-running/
stale-stopped reason) as configured.
Tests: +17 regression tests (11 for #2905 incl. full truth table + non-destructive
guard + POSIX no-op; 6 for #3194 incl. 5 no-regression guards). Full suite
7090 passed, 0 failed.
Closes#2905Closes#3194
Defense-in-depth flagged by both pre-release reviewers (Opus + Codex), both
non-blocking but cheap on a credential-handling surface:
- reject any non-http(s) HERMES_WEBUI_RUNNER_BASE_URL scheme at construction
(a misconfigured file:// / ftp:// can never reach urlopen);
- route requests through an opener that does NOT follow 3xx redirects, so a
misbehaving/compromised runner cannot smuggle the Bearer token to another host.
Both operator-misconfiguration-only (not user-reachable). +2 regression tests.
Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
Opt-in HTTP runner-client boundary for the runner-local runtime adapter
(RFC hermes-run-adapter-contract / #1925, Slice 4c/4d). Default-OFF:
without HERMES_WEBUI_RUNNER_BASE_URL the factory preserves the bounded
'not configured' path; when set, WebUI acts only as a JSON HTTP client +
SSE bridge for start/observe/status/controls without owning runner maps.
New api/runner_client.py + additive _runner_* helpers in api/routes.py;
no change to the live _run_agent_streaming legacy path.
Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
When agent checkouts track main past an older tag but the newest published
tag is on a divergent side branch, stop advertising tag-based updates and
route check/apply through the upstream branch instead.
_run_git used subprocess.run(text=True) without an explicit encoding, so on
Chinese Windows (and other non-UTF-8 codepages) git stdout was decoded with the
locale codepage. _dirty_suffix() runs `git diff --binary HEAD`, whose binary
bytes are not valid GBK, raising UnicodeDecodeError in the subprocess reader
thread. That left r.stdout = None, so `r.stdout.strip()` raised AttributeError
during module import of api.updates, crashing server.py before it could bind
its port.
Force UTF-8 decoding with errors=replace and guard against None defensively.
Per-profile WebUI state lives at <root>/webui_state (api/workspace.py), so
<base>/profiles/<name>/webui_state/sessions/*.json was reachable — it is not a
direct child of the profile root, so the prior deny-subdir loop missed it. Add
<root>/webui_state/<state-subdir> to the deny dirs for every Hermes root. Adds a
regression assertion (profile webui_state/sessions/*.json → 403).
auth.py/passkeys.py write via tmp*.<name>.tmp sidecars then rename; deny those
suffixes (.sessions.tmp, .login_attempts.tmp, .passkeys.tmp, .passkey_challenges.tmp)
under Hermes roots so a momentary temp file can't be fetched via /api/media.
Codex direct probe found three more auth-state basenames under STATE_DIR that
/api/media still served: passkeys.json + .passkey_challenges.json (api/passkeys.py)
and .login_attempts.json (api/auth.py). Add them to _DENY_FILENAMES.
Denying the whole <root>/profiles tree 403'd legitimate named-profile workspace
media (<base>/profiles/p1/workspace/shot.png). Fix: remove 'profiles' from
_DENY_SUBDIRS and instead enumerate each <root>/profiles/<name> directory as its
own Hermes root — so each profile's sensitive subdirs (sessions/memories/cron/
logs/checkpoints/backups) + secret filenames are denied, while that profile's
workspace/ is allowed via the carve-out. Adds a regression test: named-profile
workspace media serves, profile + sibling-profile auth.json stay 403.
The default workspace lives at STATE_DIR/workspace, so denying STATE_DIR itself
403'd legitimate workspace media. STATE_DIR is already in _hermes_roots, so its
sensitive subdirs (STATE_DIR/sessions, /memories, /profiles, etc.) are still
covered by the per-root subdir loop; direct sensitive files are still caught by
the filename denies. Drop the wholesale _state_dir deny. Adds a regression test
proving STATE_DIR/workspace/shot.png serves while STATE_DIR/sessions/*.json 403s.
1. routes.py: hoist a single case-folded path helper (_norm/_within_ci/_equal_ci)
used for ALL deny + carve-out comparisons (consistent macOS/Windows safety).
2. routes.py: split the deny into (a) dir-based denies that ALWAYS fire (even
inside the active workspace — so a workspace overlapping a state dir cannot
expose sessions/memories), and (b) filename denies relaxed only by the
carve-out. Fix the over-block: a workspace that is a proper DESCENDANT of a
Hermes root (e.g. STATE_DIR/workspace) is a legit project workspace and keeps
the carve-out; only a root-itself / ancestor / $HOME / profiles / state-subdir
workspace disables it.
3. ui.js: move the bare file:// media-stash pass after the raw-<pre> stash too,
so file:// inside a raw <pre> block stays literal (not just fenced/inline code).
1. api/routes.py: case-fold /api/media deny filename + dir containment checks
(os.path.normcase + casefold) so STATE.DB / Sessions/ cannot bypass the
state/secret deny on case-insensitive filesystems (macOS/Windows).
2. static/ui.js: move the bare file:// media-stash pass to run AFTER fenced-block
and inline-code stashing, so a file:// inside a code block / backtick span
stays literal text instead of becoming an auto-loaded <img>. The MEDIA: stash
keeps its first-position precedent.
Adds behavioral renderer tests (real renderMd via node) for fenced + inline code
file:// staying literal, bare file:// becoming media, and anchors keeping the
link path. Closes the last Codex review items for #3234.
Codex round-4: the carve-out could re-open the hole if the active workspace is
pathologically set to a broad/internal root ($HOME, ~/.hermes, a profile root)
— get_last_workspace only checks is_dir(), so workspace=~/.hermes would serve
state.db. Gate the carve-out: disable it when the active workspace IS, CONTAINS,
or is CONTAINED BY any Hermes root, or is $HOME / a */profiles dir / a named
profile root / an internal state subdir. Adds a unit test proving state.db stays
403 when the active workspace is the Hermes home. Widen CSP-slice test window.
Codex round-3 found the prior multi-profile hardening OVER-blocked: denying
STATE_DIR + base/profiles wholesale 403'd legitimate active-workspace media.
Redesign around a single principle: the ACTIVE WORKSPACE is the user's own
content (never deny), Hermes INTERNAL STATE lives outside any workspace (deny).
If target is inside the active workspace -> allow; else deny known secret/config
basenames + internal state subdirs across all Hermes roots. Also folds in Opus
defense-in-depth: adds cron/logs/checkpoints/backups subdirs +
gateway_state.json/channel_directory.json/jobs.json basenames. Adds an
over-block regression test (a /tmp artifact named settings_* still serves 200).
Under a named profile, process HERMES_HOME is ~/.hermes/profiles/<name> but the
allowlist still grants base ~/.hermes — so the prior deny (anchored only on the
active-profile root + STATE_DIR) left ~/.hermes/state.db and sibling-profile
secrets (~/.hermes/profiles/other/auth.json) reachable. Build deny roots from
every Hermes state root the allowlist accepts: active HERMES_HOME, base ~/.hermes,
api.profiles._DEFAULT_HERMES_HOME, and STATE_DIR; apply the state-subdir dir-denies
under each. Widen the CSP-slice structural test window to match.
- Add state.db-wal / state.db-shm (SQLite sidecars carry the same data as state.db)
- Add google_token.json / google_client_secret.json (OAuth creds)
- Scope filename-based denies to files under HERMES_HOME / STATE_DIR so a
legitimate workspace or /tmp media artifact named settings.json / config.yaml
is not wrongly blocked.
Dir-based denies (state subdirs) remain unconditional.
Pre-release dual-gate (Codex + Opus) on #3219 surfaced that /api/media serves
files under the allowlisted Hermes home, including settings.json / state.db /
auth.json / config.yaml. #3219 makes this materially worse: pre-#3219 a bare
file:// URL in agent output rendered as inert text, but #3219 turns it into an
auto-loading <img src=/api/media?path=...> that fetches on render. Rather than
weaken #3219, harden the boundary at the route: hard-deny known secret/config
filenames and the WebUI state subdirs (sessions/memories/profiles + STATE_DIR)
before the allow/serve decision, covering every entry path (bare file://,
markdown anchors, MEDIA: tokens, session-token grants). Adds a live-server
regression test. Closes#3234.
Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
Codex+Opus pre-release gate both flagged: TimeoutError is now in the
consolidated _CLIENT_DISCONNECT_ERRORS dispatch set, so a bare socket-connect
TimeoutError from Joplins urlopen(timeout=8) — which is NOT always URLError-
wrapped — would escape _handle_notes_search and be swallowed by the dispatch
disconnect handler as a fake client disconnect (silent empty response, no log).
Catch (URLError, TimeoutError) at the route so it surfaces as a clean
"not reachable" ValueError -> JSON error. Adds a regression test.
Co-authored-by: someaka <someaka@users.noreply.github.com>
OSError is too broad — it masks real errors like file-not-found.
ssl.SSLError specifically catches SSL-level disconnects without
swallowing unrelated OSError subtypes.
Closes the test_excludes_broad_oserror CI failure.
Address review feedback from @nesquena-hermes on PR #3210:
1. Deduplicate _CLIENT_DISCONNECT_ERRORS:
- Single authoritative definition in api/helpers.py
- api/routes.py now imports from api.helpers instead of defining
its own copy with different membership
- Unified tuple uses OSError (covers ssl.SSLError since it
subclasses OSError) — broad socket-level disconnect coverage
2. Remove github-search-report.md:
- Research scratch output that doesn't belong in the repo root
- Content belongs in PR description or a gist
3. Docstring improvement:
- Added comment explaining why OSError covers ssl.SSLError
- Documents the errno-level socket errors caught by OSError
- api/helpers.py: _safe_write() now logs disconnects at debug level
instead of silently passing. No more invisible errors.
- server.py: Restructure exception handlers to catch
_CLIENT_DISCONNECT_ERRORS first, then Exception. Remove the
isinstance() filter inside except Exception (LBYL anti-pattern).
The 500-response fallback now catches _CLIENT_DISCONNECT_ERRORS
separately (expected) and logs unexpected failures via
traceback.print_exc() instead of bare except Exception: pass.
- tests/test_broken_pipe_cascade.py: Add coverage for SSL/Timeout
disconnect routing and 500-response safety (both disconnect
survival and unexpected error logging).
Extract _safe_write() helper that wraps end_headers() + wfile.write()
in try/except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
TimeoutError, ssl.SSLError). Both j() and t() now use _safe_write()
instead of raw wfile calls.
Fixes cascading BrokenPipeError + SSL BAD_LENGTH crash when a client
disconnects mid-response and the error handler tries to write a 500
status through the same broken socket.
Adds a distinct two-tone attention sound (880->660Hz) for approval and
clarify prompts so they are not confused with the existing completion sound,
plus sidebar attention badges + colored rails driven by `attention` metadata
on /api/sessions. Includes lock-safety note: the in-lock
publish_session_list_changed() calls in clarify.py are safe because publish()
only takes the leaf _SESSION_EVENTS_LOCK and never re-acquires clarify._lock
(verified by Opus advisor review).
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
The new CLI/gateway insights pass counted ALL state.db sessions, but
WebUI-native sessions are persisted to state.db with source='webui' AND
already counted from the sidecar _index.json first pass — double-counting
them in totals, model breakdown, and daily charts. Add
`AND COALESCE(source, '') != 'webui'` so only CLI/gateway/cron/tui rows are
added by the second pass.
Adds regression tests proving (a) CLI + Telegram sessions appear in totals
and (b) a webui-source state.db row is not double-counted against its
_index.json entry.
Co-authored-by: wind-chant <wind-chant@users.noreply.github.com>
The Insights page () previously only counted WebUI-native
sessions from its own session index. This adds a query to the Hermes
state.db so CLI and gateway sessions (Telegram, Discord, etc.) are also
included in the aggregated token counts, costs, model breakdown, and
daily activity charts.
The state.db query is best-effort: if the file is missing or
unreadable, the existing WebUI-only data is returned as before.
Custom API aggregators (New API, One API, etc.) route requests using their
own naming conventions — bare names like ``deepseek-v4-flash`` or dot-separated
names like ``moonshotai.kimi-k2.5`` — rather than the OpenRouter-style
``vendor/model`` slash format that ``_heuristic_reasoning_efforts``'s prefix
list was written for.
Because none of these names match the slash-prefixed patterns, the function
returned ``[]``, hiding the reasoning effort selector in the WebUI even for
models that fully support thinking/reasoning.
Fix: add a secondary check in ``_heuristic_reasoning_efforts`` that strips an
optional dot-vendor prefix and matches the remaining model name against a list
of known thinking-capable bare-name prefixes (``deepseek-v4``, ``deepseek-r1``,
``kimi-k2``, ``qwen3``, ``claude-3/4``, ``o1/o3/o4``), plus a keyword catch
for model names containing ``thinking`` or ``reasoning``.
The slash-prefix path and all provider-specific fast paths (copilot, lmstudio,
models.dev metadata) are unchanged; this block only runs as a final fallback.
Regression tests added in ``tests/test_custom_provider_bare_model_reasoning.py``.
When state.db has many non-cron sessions, the normal sidebar query caps
at CLI_VISIBLE_SESSION_LIMIT (20) rows ordered by latest activity. Older
cron runs get squeezed out before _include_project_hidden_background_sidebar_sessions
can rescue them, making them invisible under their project chip.
Add a second-pass cron-only query with a higher cap (CRON_PROJECT_CHIP_LIMIT=200)
that merges into the CLI session list. The project-chip rescue layer then
marks them default_hidden so they stay addressable without polluting the
default sidebar window.
Verification: regression test seeds 25+ newer non-cron sessions and asserts
the older messageful cron session still appears with project_id set.