test_skills_stats_cache asserted that adding a skill bumps the skills-dir
mtime so the cheap per-call probe recomputes counts on the next call. Two
_write_skill() calls inside the same coarse filesystem mtime tick could leave
the skills-dir mtime byte-identical to the cached probe value, so the probe
saw no change and served the stale count of 1 (assert enabled==2 failed). This
is an order-dependent flake: passes in isolation, fails intermittently under
full-suite ordering (observed across multiple unrelated PRs).
Force the skills-dir mtime strictly forward after the second write so the
probe is guaranteed to detect a genuine mtime change. The assertion stays
honest (it still proves recompute-on-mtime-change); only the race is removed.
Two cross-file isolation leaks that a finer shard partition exposes are fixed at
root so every shard passes regardless of execution order:
- test_auth_session_persistence.py: bind api.auth STATE_DIR/_SESSIONS_FILE/
_LOGIN_ATTEMPTS_FILE in setUp + restore in tearDown (was relying on a sibling
reload test to rebind them).
- test_request_diagnostics_cache.py: pin the session-list cache source stamp via
an autouse fixture (matches test_session_sidebar_cache.py) so a leaked state.db
WAL sidecar can't cause a spurious cache-miss.
Verified all shards green under 3-, 4-, and 5-way partitions. CI infra + test
harness only; no application behavior change.
Codex found leftover quota-chip residue: #4657 had rewritten settings_desc_quota_chip
to 'Shown by default...' (and regressed several locales to English), contradicting
the reverted default-OFF behavior. Restored master's localized 'Default off / wide
displays only' copy across all 14 locales + index.html. The composer_mobile_quota
strings (mobile config panel, fit-related) are kept.
Co-authored-by: Paladin173 <Paladin173@users.noreply.github.com>
Per maintainer decision: #4657 bundled a scope-creep change flipping
show_quota_chip default off->on plus a migration force-resetting stored false->true
(stomping explicit opt-outs). Master deliberately ships quota-chip default-off.
Reverted config.py + the quota-chip wiring in panels.js/boot.js + the quota test
to master, keeping ONLY the footer fit-by-overflow engine. The mobile quota-action
selector addition (hide_composer_quota_chip selectors) is kept (fit-related, not a
default change).
Co-authored-by: Paladin173 <Paladin173@users.noreply.github.com>
Codex flagged that the fit ResizeObserver only watched .composer-footer, whose
outer width may not change when right-side controls (status/context chips)
appear — leaving the left control group's fit stale. Also observe .composer-left.
Co-authored-by: Paladin173 <Paladin173@users.noreply.github.com>
Rebased Paladin173's #4657 onto current master. The per-control visibility
toggles half already shipped via his own #4598, so this rebase carries only the
net-new JS fit-by-overflow engine (_fitComposerFooter: ResizeObserver +
MutationObserver measure .composer-left overflow, step full -> cf-icons ->
cf-burger). Co-authored-by: Paladin173 <Paladin173@users.noreply.github.com>
Codex deep-gate caught a CORE regression in the original top-N cap: capping
_apply_sidebar_state_db_overrides also capped the SOURCE classification that
/api/sessions filters on BEFORE the lazy lineage correction, so a stale-CLI JSON
row whose state.db source is webui could be silently filtered out of a WebUI-only
sidebar once it fell beyond the cap. Split the read: the indexed sessions-table
source/title lookup runs for ALL rows; only the expensive messages COUNT/MAX
aggregation (the actual 5-18s bottleneck) is capped to top-N. Added a 500-row
end-to-end regression with a real state.db proving the beyond-cap stale-CLI row
stays webui.
On power-user instances with thousands of sessions, GET /api/sessions
blocked 5-18s in the all_sessions.state_db_overrides stage:
_apply_sidebar_state_db_overrides read state.db for every row (2400+ ids)
on every concurrent poll. With the gateway-watcher reading and the agent
writing the same state.db, this became lock/IO contention that piled up
concurrent polls and flapped the UI to 'Connection lost'.
Cap the override lookup to the top-N most-recent (paint-priority) rows
that are actually visible, mirroring the existing lineage-enrichment cap
(#4638). The caller passes an already pinned-first/newest-first sorted
list, so the top-N covers the visible window; rows beyond the cap keep
their JSON source/title and are corrected lazily on history-panel open.
Cap defaults to 300, env-configurable via
HERMES_WEBUI_STATE_DB_OVERRIDE_TOP_N (0 disables). Fails open.
Reported by @latipun, reproduced by @b3nw.
Co-authored-by: latipun <latipun@users.noreply.github.com>
The sidebar_reference_sessions payload arrives project-unscoped from
/api/sessions. _renderSidebarRowsFromRawSessions appended it to EVERY
render unconditionally, so an archived parent in project B entered a
project-A-filtered render's suppression context and silently hid a
visible project-A child/fork (rendered as []). Add
_scopedSidebarReferenceRows() to filter references to the same project +
webui/cli source bucket as the render they feed, mirroring
_partitionSidebarSessionRows. Regression test locks the cross-project
case; the existing suppression test now exercises the scoped helper.
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
The new-conversation splash mark was wrapped in a glossy navy rounded-rect
that reads as a macOS app-launcher tile ('this is an app') rather than a
brand mark. Major AI chat UIs (Claude, ChatGPT, Gemini) all render their
empty-state mark bare. Remove the tile, scale the winged caduceus up
(~88px), and back it with a soft cyan/blue radial 'spirit' bloom.
Light and dark get separately tuned marks: dark keeps the bright
cyan->blue gradient + luminous bloom; light uses a deeper #0AA6CC->#1E63D6
gradient + cooler restrained bloom so the mark holds contrast on the pale
background instead of washing out. Entrance is gated behind
prefers-reduced-motion.
The 4 terminal-auth-failure tests assert an unrecoverable 401 surfaces an
apperror / persisted _error turn. The settlement path first attempts the
#1401 credential self-heal, which reads ~/.hermes/auth.json. On a host with
populated credentials, self-heal can succeed and retry the mock agent,
swallowing the error the test expects -- so the result depended on host
credentials (green on CI / Windows with no creds, red on a live agent box).
Add an autouse fixture that forces _attempt_credential_self_heal to a no-op
so every host exercises the unrecoverable-failure path. The one test that
verifies a successful retry already patches the symbol in its own body, so it
is unaffected.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
PR #5113 (new cyan caduceus brand mark) intentionally removed the per-skin
.app-titlebar-icon rect/path/circle recolor overrides — the old gold mark was
forced to the skin accent, but the new brand badge is self-colored and constant
across themes. test_graphite_skin_tunes_workbench_chrome still asserted that
removed rule. Dropped the obsolete assertion; the rest of the graphite chrome
coverage is unchanged.
Mirror the worker's LRU-eviction ACTIVE_RUNS liveness check in
_evict_session_agent so truncate/clear/model-switch can't close the SessionDB
out from under an in-flight turn on the same session (#5096 Bug D follow-up).
The cache handle still drops (harmless — worker holds a local ref); only the
lifecycle commit + _session_db.close() are skipped while a run is active.
Co-authored-by: claw-io <claw-io@users.noreply.github.com>
Salvages PR #3976 by ksj5131513-ops, ported fresh onto current master and
re-applied on top of #4195's external-refresh gate (the original PR predated
#3916/#4195's rework of refreshActiveSessionIfExternallyUpdated).
Two parts:
1. Avoid an unconditional same-session force reload the moment streaming
settles. _scheduleActiveSessionIdleReload previously called
loadSession(force:true) on every idle reconcile, which on mobile PWA caused
a visible end-of-turn flash and could briefly restore the pane with stale
layout geometry. It now reconciles against server metadata first via
refreshActiveSessionIfExternallyUpdated('idle-reconcile', {ignoreStreamJustFinished:true})
— reloading ONLY when the message count actually changed — and falls back to
the original forced reload only if the metadata probe itself fails.
This COEXISTS with #4195: the poll-only external-session early-return
(if((reason||'poll')==='poll' && !_isExternalSession(S.session)) return) is
left untouched. The idle path uses a non-'poll' reason so it never trips that
gate, and opts (read via arguments[1], same pattern as loadSession) only
bypasses the post-stream cooldown, not the external gate. The function keeps
its (reason) signature and now returns a status string
('skipped'/'unchanged'/'reloaded'/'failed') for callers that want it.
2. Mobile viewport reflow guard for PWA visualViewport changes. When the
on-screen keyboard / browser chrome shows or hides, the phone layout can stay
painted against stale geometry. _forceMobileViewportReflow() toggles a
one-frame html.viewport-reflow class (cheap GPU-promotion transform) to force
a repaint, then resyncs the workspace panel + sidebar aria. Wired to
visualViewport resize/scroll (debounced) where available, falling back to the
window resize handler otherwise. Adds safe-area-inset padding to topbar,
messages-inner, and composer-wrap on phones. Uses overflow-x:clip (NOT hidden)
on .layout per the #4856 lesson to avoid coercing overflow-y into a scroller.
Co-authored-by: ksj5131513-ops <ksj5131513-ops@users.noreply.github.com>
A config.yaml with an explicit empty/null `providers:` key parses to None in
PyYAML. `cfg.get("providers", {})` only returns the {} default when the key is
*absent*, so an explicit null yields None instead of an empty mapping. master
already guards each read with `isinstance(providers_cfg, dict)`, but the value
fed to that guard is still a footgun: the natural chained form
`cfg.get("providers", {}).get(...)` (already fixed once in api/config.py)
crashes with AttributeError on None.
Harden every providers-key read in the three remaining files to
`cfg.get("providers") or {}` so an explicit null degrades to an empty dict at
the source, matching the existing api/config.py convention. Sites hardened:
api/onboarding.py:578 (_provider_api_key_present)
api/providers.py:1051 (_provider_has_shadowed_codex_oauth_value)
api/providers.py:1219 (_provider_has_key)
api/providers.py:1265 (_get_provider_api_key)
api/providers.py:2348 (get_providers catalog)
api/providers.py:2781 (_clean_provider_key_from_config)
api/routes.py:5045 (_context_length_config_api_key_for_provider)
api/routes.py:5110 (_context_length_lookup_inputs_for_model)
api/routes.py:15916 (_handle_live_models)
Adds tests/test_none_providers_config_guard.py: per-file source-form pins (fail
on master / on any single-file revert) plus behavioural checks driving the real
functions with `providers: None` to prove no crash and parity with the
empty-mapping config. Updates the existing test_issue3717 routes-string pin to
match the hardened form.
This is a focused salvage of grab-bag PR #3967 -- only the None-providers
hardening is taken; the PR's unrelated changes (account-usage worker-pool
refactor, function removals) are intentionally dropped.
Co-authored-by: lidi1011 <lidi1011@users.noreply.github.com>
WebUI's api/streaming.py:_resolve_image_input_mode was a LOCAL
re-implementation of agent/image_routing.py:decide_image_input_mode that
had DIVERGED from the canonical function. The local copy never consulted
the active model's vision capability — it only checked a handful of config
heuristics — so it returned "native" (embedding the image as an image_url
part) even for models KNOWN to lack vision. That silently sends pixels to a
blind model (#21160-class failure) instead of routing through the text
(vision_analyze) pipeline.
Fix: delegate to the canonical agent.image_routing.decide_image_input_mode
(single source of truth), with a try/except fallback that preserves the
historical WebUI behaviour when the agent package is not importable (e.g.
the WebUI standalone test environment).
Carve-out preserved: the canonical router conservatively returns "text" for
UNKNOWN/custom models (no models.dev capability data). WebUI historically
forwards those NATIVELY and relies on the agent's strip-and-retry guard
(_try_shrink_image_parts_in_messages / _strip_images_from_messages) to
downgrade on a provider rejection. We keep that: a canonical "text" verdict
is only honoured when there's a real signal — an explicit user choice
(image_input_mode: text or a configured auxiliary.vision backend) or a model
KNOWN to lack vision. Otherwise we forward native.
Tests:
- Existing gateway test (forwards_image_attachments_as_multimodal_parts)
stays green — unknown "test-model" still forwards native.
- New non-vacuous regression: a model KNOWN to be text-only
(supports_vision == False) now routes "text" — this FAILS against master,
where the old local copy returned "native" for the same config.
- Added coverage for the unknown-model carve-out, known-vision native path,
explicit-text-signal precedence, and the agent-unavailable fallback.
Salvage of #4113 by @gkd2323c. The original PR delegated fully to canonical
but did not add the unknown-model carve-out, which would have regressed the
WebUI native-forwarding behaviour the gateway test encodes.
Co-authored-by: gkd2323c <gkd2323c@users.noreply.github.com>
Surfaces prompt-cache hit rate on the Insights page so cache-efficiency
(a key cost-optimisation signal) is visible at a glance.
Backend (api/routes.py, _handle_insights):
- Aggregate cache_read_tokens per model and per day, plus a total.
- Graceful fallback for older agent state.db schemas lacking the
cache_read_tokens column (COALESCE + OperationalError second query).
- New response fields: total_cache_read_tokens, total_cache_hit_percent,
per-model cache_read_tokens + cache_hit_percent, per-day cache_read_tokens.
Frontend:
- Models table gains a Cache column (panels.js + style.css 6-col grid).
- Daily bar tooltip includes the bounded cache hit rate.
- New i18n keys insights_model_cache / insights_cache_hit in all 13 locales.
Correctness fix (maintainer-flagged on #3912): the original computed
hit-rate as cache_read / input_tokens (reads-over-misses), which EXCEEDS
100% on cache-heavy sessions. This uses the FULL prompt total as the
denominator -- cache_read / (input_tokens + cache_read) -- via the shared
api.usage.prompt_cache_hit_percent helper, so it is bounded 0-100% and
means "% of the prompt served from cache". Returns None (rendered as a
muted dash) when there are no cache reads.
Tests (tests/test_insights.py):
- Updated existing assertions for the new response shape.
- Added a non-vacuous <=100% boundary test: a cache-heavy case
(cache_read=9900, input=100) where the OLD formula reports 9900% but the
bounded denominator yields 99%. Verified the test FAILS under the old
formula and PASSES under the fix.
Salvage note: PR #3912's diff was ~99 releases stale (mostly re-derived old
code, illusory +5405). This ports ONLY the real ~190-line feature fresh
onto current master.
Closes#3911
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
New brand caduceus (vector mark, cyan #08EBF1->#3889FD) on a radial-depth navy
badge. Favicon family regenerated (svg/ico/png/apple-touch). Titlebar and
empty-state logos now use the self-contained navy badge so they render correctly
on every theme (light + dark) without washing out. Removed 5 per-skin CSS rules
that force-filled the titlebar icon to a flat accent color (they assumed the old
monochrome caduceus).
CLI/TUI/Desktop sessions were *recreated* on every POST to /api/chat/start
instead of *claimed*, because the claim/synthesis logic only ran on the GET
stub path. GET /api/session already synthesized a read-only stub from
state.db, but POST /api/chat/start unconditionally 404'd on any session_id
without a WebUI JSON sidecar — and the frontend 404 handler then ran its
empty-state self-heal, stripping the URL and silently discarding the user's
typed message.
This closes the GET/POST asymmetry by routing both endpoints through a new
shared helper, _claim_or_synthesize_cli_session, that materialises a
WebUI-owned Session on first write (via synth.save()).
Preserved guards:
- Denylist of foreign-owned sources via _is_claimable_cli_source:
{claude_code, cron, external_agent, gateway, messaging, unknown}; cli/tui/
desktop fall through to CLAIMABLE.
- The not_claimable arm returns 403 (NOT 404) and never reaches synth.save()
(ownership guard). 403-vs-404 matters: the frontend 404 handler runs the
empty-state self-heal that strips the URL, so a legitimately-listed
read-only session must surface a refusal instead of vanishing.
- created_at mapping from state.db is load-bearing (Session.save() doesn't
touch created_at), so claimed sidecars no longer sort as "Jan 1 1970".
- The 500 path uses _sanitize_error so a raw OSError can't leak the sidecar
filesystem path.
- The #2782 deleted-WebUI-session 404 self-heal contract is preserved via
_session_index_marks_was_webui.
- The GET path's _session_visible_to_active_profile gate is preserved.
Polish over the original PR:
- Test docstring rationale corrected re: updated_at — Session.save() defaults
touch_updated_at=True so the claimed sidecar's updated_at is stamped to
"now" regardless; only created_at is preserved. Assertions unchanged.
- touch_updated_at=True default kept (a claimed session jumps to "just now",
which is the desired UX); no test pins persisted updated_at to the state.db
value.
- Added a path-leak assertion to the 500-sanitisation test: the sanitized
string must not contain a '/' segment from the session-store root.
Salvage of #4153.
Co-authored-by: merodahero <merodahero@users.noreply.github.com>
Codex gate found 2 functional bugs:
1. registerHermesTtsEngine was inside the voice-mode IIFE, which returns early
on browsers without SpeechRecognition/speechSynthesis — so the documented API
didn't exist there. Moved the registry (registerHermesTtsEngine / _hermesTtsSynth
/ option helpers) to module scope so it runs unconditionally.
2. autoReadLastAssistant() ignored a selected registered extension engine (only
handled elevenlabs/edge, fell through to browser TTS). Added the registered-engine
branch mirroring speakMessage(), with graceful fallback when the saved engine
is no longer registered.
Lets a trusted local extension contribute a TTS engine that appears in the
Settings -> TTS Engine dropdown and is used by BOTH playback paths (voice-mode
auto-read in boot.js _speakResponse + the per-message Listen button in ui.js
speakMessage). Mirrors window.registerHermesSkin.
window.registerHermesTtsEngine({ id, label, synthesize(text, opts) })
-> Promise<ArrayBuffer|Blob> of audio; core coerces + plays through the same
<audio> lifecycle as the Edge engine (stop/rearm in voice mode).
Core owns selection, the dropdown <option> (label via textContent, never HTML),
and playback. Built-in ids (browser/edge/elevenlabs) are reserved; invalid
descriptors are rejected; empty/failed synth degrades gracefully. The settings
panel re-adds registered options on render (the markup hardcodes only built-ins).
Unblocks TTS-engine extensions (e.g. a local VOICEVOX engine). Docs in
docs/EXTENSIONS.md; CHANGELOG [Unreleased]. New regression test drives the real
registry via a Node harness (register/validation/reserved-guard/synth coercion).
Full suite: 10893 passed, 0 failed. Verified end-to-end in a live browser with a
companion VOICEVOX extension: engine registers, appears in the dropdown
(browser/edge/elevenlabs/voicevox), and the Listen button routes through the
registered synthesize (both VOICEVOX steps called).
Codex flagged that _buildSkinPicker injected the extension-controlled skin
label/name via btn.innerHTML, so a malicious registerHermesSkin descriptor
(label='<img src=x onerror=...>') could execute when Settings -> Appearance
builds the picker. Rebuild the button with DOM nodes + textContent for the
label and set swatch backgrounds via element.style.background. Adds regression
tests asserting the innerHTML sink is gone.
Found via live browser testing of the first consumer (e-ink skin):
- An extension-registered skin selected by the user was lost on reload: both
the pre-paint inline script in index.html AND the boot-time appearance sync
normalized the persisted skin against the (extension-less) known-skins list
and clobbered localStorage back to 'default' before the extension's
registerHermesSkin() could run. Both sites now PRESERVE an unknown non-default
persisted skin (a likely pending extension skin), so it survives the reload
and the extension re-applies it on load. The server push is skipped for a
still-pending ext skin.
- Expanded the token allowlist to the full accent family
(--accent-hover/-text/-bg/-bg-strong/-rgb + --surface-subtle) so a skin can
fully recolor accent-driven chrome (active rail, chips, profile labels), not
just --accent. Value regex now also accepts a bare RGB triple for --accent-rgb.
Regression tests added for both preservation sites. Full suite: 10876 passed.
Lets a trusted local extension contribute a custom skin to the NATIVE
Settings -> Appearance picker (instead of a parallel theme switcher).
window.registerHermesSkin(descriptor) validates + sanitizes the skin in
core, once, so every theme extension inherits the guard:
- token names restricted to a documented allowlist
- values sanitized against a strict color/dimension regex (rejects url(),
expression(), semicolons, braces, and other CSS-injection vectors)
- reserved built-in skin keys cannot be overwritten
- descriptor with no valid tokens is refused
- idempotent re-register (updates in place) for a live theme editor
Injects a managed <style> for the :root[data-skin] tokens, appends to
_SKINS + _VALID_SKINS, refreshes the picker, and re-applies a persisted
selection that was waiting on a late-registered skin.
Unblocks theme-pack and theme-creator extensions. Documented in
docs/EXTENSIONS.md. Regression test drives the real registration +
sanitization logic via a Node harness (adversarial input).
Full suite: 10874 passed, 0 failed.
Adds a configurable frame-src directive so an extension can embed a
self-hosted web app in an iframe (e.g. an 'external app tab' pinning
Grafana / Vaultwarden / a dashboard). Mirrors the existing
HERMES_WEBUI_CSP_CONNECT_EXTRA knob: space-separated http(s) origins with
optional *.subdomain wildcard + port, validated, ignored if malformed
(directive-injection / paths / ws scheme / bad ports all rejected).
- Default frame-src is 'self' only, so existing same-origin iframes are
unchanged; default-off when the env var is unset.
- frame-ancestors stays 'none' -- this only governs what THIS page may embed,
never who may embed the WebUI.
- Threaded through both the enforced and report-only CSP builders + the
server.py Handler wrapper, so both headers stay identical.
Docs in docs/EXTENSIONS.md. 8 new regression tests
(tests/test_csp_frame_src_extra.py) cover default, valid widening, enforced
parity, directive-injection rejection, path/ws/port rejection, and
connect/frame independence. Existing CSP test suite still green.
Note: full suite shows 2 pre-existing order-dependent flakes
(test_issue1574 spawn-context, test_issue3283 import-order) that pass in
isolation and are untouched by this CSP-only diff.
Adds the two remaining enhanced-table-copy boundary cases flagged as optional
coverage when #4994 (fixes#4945) shipped: a selection anchored on the <table>
node itself, and a selection that starts in a cell and ends in surrounding prose,
both must fall through to native copy. Test-only; the shipped guard already
handles both correctly (no production change needed).
A threading.Timer per RequestDiagnostics spawns one OS thread per request, held
alive until finish() cancels it. Under sustained /api/sessions poll load this
exhausts the per-process thread cap (RuntimeError: can't start new thread) and
the server stops accepting connections. Replace with a single lazy-init
watchdog daemon thread that scans pending diags every ~1s; public API
unchanged. Caps timeout-tracking at 1 thread/process regardless of request rate.
#4771 added an explicit relay-failure 409 (code: gateway_run_unavailable)
to /api/approval/respond so a gateway approval whose run is gone keeps its
card actionable instead of silently failing. But the 409 was emitted
regardless of backend.
On the DEFAULT local in-process backend, every guarded command parks an
entry in tools.approval._gateway_queues (via _await_gateway_decision), and
the local streaming notify callback mirrors it into _pending tagged with
_GATEWAY_MIRROR_FLAG. There is no gateway run and no _STREAM_RUN_IDS entry
by design. _gateway_pending_approval_without_run_id() therefore returned
True for that purely local approval, so the handler 409'd
("active run unavailable") and refused to resolve an approval that resolves
perfectly well locally. Local-backend users hit this on every approval
click (reported by b3nw on Discord; independently filed as #4948 by
claw-io, which also notes the local-runtime trigger).
Fix: gate the 409 on the WebUI actually running the gateway chat backend
(webui_gateway_chat_enabled). Local approvals fall through to the local
resolution path; the legitimate #4771 gateway behaviour is unchanged.
Tests:
- New tests/test_issue4771_local_approval_regression.py drives the real
_handle_approval_respond handler and asserts: local backend -> 200 +
agent thread unblocked; gateway backend -> 409 preserved. These fail
before the fix (409) and pass after.
- The two existing #4771 tests that asserted the 409 are updated to pin
HERMES_WEBUI_CHAT_BACKEND=gateway at respond time, since the 409 is now
gateway-deployment behaviour.
Reported-by: b3nw
Reported-by: claw-io (#4948)
Opus SHIP-WITH-FIXES (no code change required): the comment/docstring overstated
that the session-list-change listener covers ALL structural mutations. In-app
mutations fire it, but externally-driven changes (scheduled cron completion,
external CLI writes) don't — for those the 30s streaming TTL is the load-bearing
backstop (≤30s bounded, self-healing lag). Comment-only accuracy fix; logic
unchanged (both gates: perf fix correct, freeze effective, staleness bounded).