The embedded workspace terminal spawns a PTY shell that runs arbitrary
commands as the server-process user. check_auth() returns True
unconditionally when no password/passkey is configured (the default
out-of-the-box state), so without a network-scope gate the terminal
endpoints were reachable by any unauthenticated caller able to hit the
port — which on a passwordless public bind is remote code execution.
Apply the same local-origin gate the onboarding/bootstrap endpoints use
(_onboarding_gate_allows) to /api/terminal/{start,input,resize,close} and
/api/terminal/output: with auth disabled, accept only loopback/private
origins, ignore spoofable X-Forwarded-For/X-Real-IP unless
HERMES_WEBUI_TRUST_FORWARDED_FOR=1, and honor HERMES_WEBUI_ONBOARDING_OPEN=1
as the explicit opt-out for a deliberately-exposed server. Auth-enabled
servers (cookie already verified upstream) and genuine same-host clients
are unaffected.
Also fixes a latent test-isolation leak in
test_extension_route_remains_behind_webui_auth: it set HERMES_WEBUI_PASSWORD
but never invalidated the process-wide password-hash cache, so its result
depended on suite execution order (exposed when the new test file shifted
ordering). Invalidate before+after so it reads the env var deterministically.
12 new gate tests in tests/test_cvd3_terminal_local_origin_gate.py.
A public TTS host could 301/302/303-redirect POST /audio/speech to an internal
target (e.g. http://169.254.169.254), and urllib's default redirect handler
would follow it carrying the Authorization bearer — both an SSRF bounce past the
base-url guard and a credential leak. Now uses a no-redirect opener
(_NoRedirectTtsHandler raises on any redirect) via the _tts_open seam. +redirect
rejection regression test. Residual DNS-rebinding TOCTOU (re-resolve at connect)
is a narrower low-severity window noted for follow-up.
The base_url validator accepted any https host; an https URL pointing at an
internal/link-local/loopback/reserved IP (e.g. https://169.254.169.254 cloud
metadata, https://10.x internal) passed the scheme-only check. Now resolves the
host and rejects blocked-target addresses (private/loopback/link-local/reserved/
multicast/unspecified), while still allowing public OpenAI-compatible hosts and
the explicit localhost-over-http dev case. DNS-resolution failure is allowed
(unreachable host can't be an SSRF vector + avoids false-rejecting public hosts
that don't resolve in sandboxed envs). +5 regression vectors.
A visible tab can permanently miss a server-initiated turn (self-wake /
cron / restart hook) when its per-session SSE is momentarily down at the
emit instant. server_turn_started is a fire-and-forget broadcast with no
replay buffer; if the turn also finishes before the tab reconnects, the
on-subscribe self-heal finds no live run in ACTIVE_RUNS and replays
nothing, so the transcript stays stale until a hard refresh.
Extend the self-heal: the (re)subscribing tab reports its last-known
message_count (?known_count); when there is no live run to replay, the
handler compares the persisted count and, if the server is ahead, emits a
lightweight session-updated frame. The frontend syncs incrementally via
the #5189 keepStaleUntilLoaded swap-in-place loadSession path (no
clear+refetch, so the #5177/#5189 blank-gap jump is not reintroduced).
Idle-only and pane-scoped; emits only when the server is strictly ahead.
Gate hardening for #5198: the new stream-owner map leaked entries on three
paths that bypass the teardown finally — worker early-return when the stream
was cancelled before startup (streaming.py, gateway_chat.py) and the direct
ACTIVE_RUNS stale-zombie prune (routes.py). Each now calls unregister_stream_owner.
Adds a regression test proving the early-return path no longer leaks.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Mirror the #5128 fast-path suffix repair when profile context is present
but requested_provider is omitted, which async delegation completion can
trigger via process_wakeup.
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Prevent historical assistant reasoning_content from being replayed to
generic/local providers by default while preserving current behavior for
known reasoning-capable provider protocols.
Adds a webui.reasoning_content_replay policy read by the sanitizer:
auto, strip, and preserve. Auto strips for local/generic backends such as
LM Studio/Ollama/llama.cpp and preserves for explicitly known provider
protocols that may require reasoning replay.
This only affects provider-facing sanitized history. It does not modify
stored session/display reasoning metadata.
Codex flagged that get_last_workspace() falls back to the GLOBAL last_workspace.txt
before the profile config.yaml — so a named profile without its own
last_workspace.txt would leak the global path, re-introducing the #5169 bug.
Add get_profile_default_workspace() (profile last_workspace.txt -> config.yaml ->
terminal.cwd -> default, NO global fallback) and call it from /api/profile/active.
Adds a regression test: global last_workspace.txt present + named profile with
only config.yaml workspace must return the profile path, never the global.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Expose a profile-scoped default_workspace on GET /api/profile/active (resolved
by the profile-aware get_last_workspace(), fail-open) and hydrate boot.js from
it, overriding the global GET /api/settings value. GET /api/settings stays
global-only so the settings UI can't clobber the shared default. Combines
claw-io's #5173 (primary) with the fail-open backend hardening + regression test
from #5171.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
When session model_provider differs from profile model.provider, do not
expose profile_default_model for suffix repair. Fast path also requires
profile_provider to match requested_provider when profile_provider is set.
Addresses cross-provider mis-resolution (custom:other-proxy + bare suffix).
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Refs #5127.
When _resolve_compatible_session_model_state takes the #1855 fast path with
a bare runtime model suffix and a slash-qualified profile default, repair to
the full qualified ID for custom/custom:* providers.
Also load profile model.default when the session already has model_provider
so wakeup turns can apply that repair.
Co-authored-by: b3nw <b3nw@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>
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>
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>