The README IA pass moved the Gateway-backed-chat operator docs out of the README
into docs/advanced-chat-setup.md (niche self-hosted feature). The contract test
test_gateway_chat_health_payload_is_documented_as_operator_diagnostic_only
asserted those phrases live in README.md — update it to follow the content to its
new home (CHANGELOG entry unchanged). Contract test moves with the content per
docs/CONTRACTS.md. Full suite: 7138 passed, 0 failed.
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>
- test_pr1341 distance limit 9000→13000 (the PR legitimately added the
default-only pre-save guard block; the test is designed to be bumped when a
new pre-save mutation block lands — this was the only CI-red cause on shard 1).
- new tests/test_issue3256_context_length_default_only_guard.py: verifies the
global model.context_length cap applies ONLY to model.default (revert-fix-
verified — fails on master, passes with the fix).
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Codex regression gate found the launchd guard blocked ANY ctl.sh start while a
launchd job was live — including a legitimate second instance on a different
port (HERMES_WEBUI_PORT=8788). Now _launchd_webui_pid only treats the launchd
job as a conflict when its PID is actually listening on the requested CTL_PORT
(via a new best-effort _pid_listens_on_port helper using lsof); a different-port
start is allowed. When port ownership can't be determined (no lsof), falls back
to guarding only the default 8787 port so non-default ports are never wrongly
blocked. Adds a different-port-allowed regression test + makes the existing
block test deterministic. Co-authored-by preserved.
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
Shift from backend mtime-based detection to frontend SSE deduplication.
Backend: Revert gateway_watcher.py to original pure hash-based polling.
Remove _get_db_mtime, _detect_gateway_restart, and mtime tracking.
This is a no-op in behavior — the original was already hash-only.
Frontend: Add deduplication at the SSE event handler level.
- _gatewaySessionSnapshotKey(sessions): deterministic key from
session_id + updated_at + message_count (same fields as backend hash)
- _isGatewaySessionForSnapshot(session): classify non-webui sessions
- _isDuplicateGatewaySessionSnapshot(sessions): compare SSE payload
against current _allSessions, filtered to gateway subset
- SSE sessions_changed handler wraps renderSessionList() in dedupe:
identical data → skip refresh
This directly addresses the real root cause: the SSE reconnect snapshot
(routes.py:7735) unconditionally pushes an initial snapshot, and the
frontend always re-renders. After this fix, a reconnect with unchanged
session data is correctly detected and the redundant redraw is skipped.
Previously submitted as #3259 (backend mtime approach, now closed per
maintainer review).
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>
Pre-release Codex regression gate caught that _normalizeArtifactPath()
did not strip ./ or ~/ prefixes, so a tool arg recorded as ./foo.md did
not match a file-tree-opened foo.md in _turnMutatedPreviewPaths — the
open preview was left stale after an agent edit via a ./-prefixed path.
Strip ~/ and leading ./ before ignore/membership checks. Node-driven
regression test pins foo.md == ./foo.md == ~/foo.md and confirms the
existing ignore-dir / URL / empty rejections still hold.
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
Track write/edit tool paths per turn, refresh the open preview on
tool_complete and after preservePreview loadDir on stream done, without
closing preview for unrelated responses or wiping unsaved local edits.
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.
Codex round-2: _stopMic and mediaRecorder.onstop read the CURRENT _rawAudioMode
to choose backend/dispatch, but the recording was started on the OLD mode — so
toggling Settings→Sound mid-recording could stop the wrong backend (orphaning
the other) or dispatch raw-vs-transcribe wrongly. Pin _activeCaptureMode
(speech | media-raw | media-transcribe) at start; _stopMic + onstop use it.
Adds front-end source-invariant regression tests.
Co-authored-by: lucasrc <lrclucas@gmail.com>
- 'Badge' naming was misleading since the visual badge was removed
- Use monkeypatch.setitem with pytest fixture instead of manual
import sys + del sys.modules (safer cleanup, no inline import)
- All 9 tests pass
- Settings toggle in Sound section (after voice mode button)
- Mic button shows 'RAW' badge when raw audio mode is active
- Raw mode: record → pending file → send() (auto-send if textarea empty)
- Dictation mode: unchanged (transcribe → textarea)
- 12 locales with full i18n keys and translations
- 4 backend tests for upload + transcribe regression
PR: Thinking Path
- Hermes WebUI has dictation via Web Speech API / MediaRecorder → /api/transcribe
- Telegram sends raw audio as attachment, agent decides what to do
- Raw audio mode enables external STT, emotion/noise analysis, multimodal models
What Changed
- static/index.html: settingsRawAudio checkbox in Sound section
- static/boot.js: raw audio preference, send raw blob as pending file
- static/style.css: badge RAW on mic button
- static/i18n.js: 12 locales with translations
- tests/test_raw_audio_upload.py: 4 tests for upload + regressions
AI Usage
- Provider: opencode-go
- Model: deepseek-v4-flash
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).
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. 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.
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.