Commit graph

2413 commits

Author SHA1 Message Date
Konstantin M
2a4e86f5c3 fix(webui): preserve user_message_count under DB contention
Greptile flagged that _compute_user_message_count_lazy silently returns 0
when the SQLite query fails (DB locked, missing file, schema drift).
The pre-patch compact() walked self.messages in Python and returned the
correct count without ever touching the DB, so the silent zero
fallback is a regression that breaks _looks_like_stale_zero_message_row
under contention.

Fix: helper now takes an optional messages argument. On SQLite failure
it falls back to the same in-memory walk the original code used, so
callers see the same number they'd have seen before for any session
where Session.load() populated self.messages.

Also drop the dead ('GET', '/api/session') allowlist entry flagged by
Greptile — the /api/session handler already does its own per-stage
logging via the _t0.._t6 block (with auto-log on slow requests), and
adding RequestDiagnostics on top would just duplicate the slow-request
journal entries without adding information.
2026-07-06 18:34:02 +00:00
b3nw
e8f7e28a3c fix: address gate cert BRICKs + Greptile P1 on PR #5688
Address the RED gate cert on PR #5688 (2 BRICK + 1 CORE) and the
incoming Greptile review (1 P1 + 3 P2):

- BRICK-1 (race-safety): drop the mtime heuristic and any age-based
  lock removal. Add /api/updates/clear_lock with a fail-closed
  fcntl.flock holder probe (refuses if any process still holds the
  file, on POSIX). On non-POSIX, fails closed. Touches only
  .git/index.lock (the only well-known short-lived lock git creates).
- BRICK-2 (destructive path coupling): remove lock_conflict from the
  force-button condition. Add a separate btnClearUpdateLock that calls
  /api/updates/clear_lock -- never apply_force_update. The recovery
  path never runs checkout/clean/reset --hard.
- CORE-1 (unconditional pre-cleanup): apply_force_update no longer
  iterates .git/**/*.lock. Lock cleanup is exclusively the clear_lock
  endpoint's job.
- Greptile P1: when a pull-lock error fires after a stash was pushed,
  run git stash pop (with apply+drop fallback) so the user's local
  modifications aren't stranded in the stash silently. v2 tests
  cover the stashed and the clean cases.
- Greptile P2 (broad 'lock file' substring): tightened _GIT_LOCK_SIGNATURES
  to specific git error strings ('index.lock': file exists, '.lock':
  file exists, 'another git process seems to be running',
  'unable to create .git/index.lock') so unrelated ref-transaction
  lock-loss messages no longer trigger a lock-conflict response.
- Greptile P2 (redundant git_dir.exists / magic number): both gone as a
  side effect of removing the apply_force_update cleanup loop entirely.

Refactor: rebased onto current upstream/master so PR is no longer
'behind' 130 commits.

Tests:
- 14 new v2 tests covering holder probe (true/false/NOGIL probe),
  _try_remove_lock (refuse-on-held/success-on-unheld),
  apply_clear_lock (success/refused), signature set parameter table
  including false-positive class, apply_force_update no-touch
  contract, pull-lock stash restore, pull-lock no-stash-when-clean.
- 2 v1 tests removed (they encoded the unsafe behavior).
- 2 v1 parametrize rows dropped (their positive cases relied on the
  broad 'lock file' substring).

Closes #5687
2026-07-06 17:37:05 +00:00
b3nw
3c5b00f928 fix: detect and recover from stale git lock files during self-update
- Detect lock file errors (lock_conflict flag) on fetch/status/pull
  failures in _apply_update_inner (#1)
- Expose Force Update button on frontend when lock_conflict is
  received (#3)
- Proactively clean stale lock files (>30s old) in apply_force_update
  using rglob discovery instead of a hardcoded path list (#2)
- Add 10 tests: _is_git_lock_error parametrized unit tests,
  _apply_update_inner lock path coverage, and apply_force_update
  stale/recent lock behavior
2026-07-06 17:17:21 +00:00
Konstantin M
60e1b5277d perf(webui): bound _last_message_timestamp to a tail window
Session.compact() called _last_message_timestamp(self.messages) which
reversed-walked all 2,730 messages on every /api/session response.
The messages array is chronologically ordered, so the last non-tool
message sits at the very end. Scan only the last 8 messages; fall
back to a full scan only if no timestamp is found in the window
(unusual sessions with >8 trailing tool rows).

Reverts to identical behavior when the window hits, since the most
recent user/assistant message's timestamp is what compact() emits.

Bench (single-shot, live Chromebook eMMC):
  before: get_session=1107ms compact=1725ms total=2880ms
  after:  get_session= 648ms compact= 288ms total=1313ms
  (-80% on compact, -54% on total)

Revert: git checkout master && systemctl --user restart hermes-webui
2026-07-06 16:53:01 +00:00
Konstantin M
af67bd5968 perf(webui): Priority 1+4 - cheap user_message_count + profiles TTL bump
Priority 1: Session.compact() walked all self.messages to count user-role
messages. For a 2,730-msg session that's ~1.5s on eMMC. Replaced with
Session._compute_user_message_count_lazy(): single indexed SQLite query,
~5ms, same correctness. Field is consumed by sidebar-row code
(_looks_like_stale_zero_message_row, _row_may_need_sidecar_metadata_refresh)
so we MUST keep emitting a real value, not None.

Priority 4: _LIST_PROFILES_CACHE_TTL 4s -> 60s. Invalidation hooks
(create_profile_api, delete_profile_api) already call
_invalidate_list_profiles_cache() so the bump is safe.

Bench (10 iters, 10s timeout, live Chromebook eMMC):
  long_session_idle   p50  5759ms -> 2869ms  (-50%)
                     p95  9141ms -> 3156ms  (-65%)
                     max  9740ms -> 3283ms  (-66%)
                     timeouts 2/10 -> 0/10
  session_switch_idle p50  2336ms -> 1316ms  (-44%)
                     p95  3708ms -> 1442ms  (-61%)
                     max  4218ms -> 1525ms  (-64%)
  profiles_idle       p50    6ms ->    6ms
                     p95  821ms ->  248ms   (-70%)
                     max 1488ms ->  445ms   (-70%)
  sidebar_idle        p50  123ms ->   31ms   (-75%)
                     p95  887ms ->   57ms   (-94%)
                     max  986ms ->   59ms   (-94%)
  normal_session_idle p50  359ms ->   56ms   (-84%)
                     p95  749ms ->  131ms   (-83%)
                     max  794ms ->  167ms   (-79%)

Revert: git checkout master && systemctl --user restart hermes-webui
2026-07-06 15:40:29 +00:00
Rod Boev
9b3538f1a6 fix(streaming): reject hostile session SSE cursors (#4812) 2026-07-06 11:25:59 -04:00
Konstantin M
ce1aaf9d7b perf(webui): auto-log slow /api/session requests with stage breakdown
Adds auto-logging when total /api/session latency exceeds 2s, so we don't
need HERMES_DEBUG_SLOW env var to diagnose regressions in production.
Env var still forces logging on every request for development.

Baseline data on the Chromebook (Celeron N3350, eMMC storage):
- long_session_idle (2,445 msgs) p50=5.8s, p95=9.1s, max=9.7s, 2/10 timeouts
- stage breakdown for a 2.8s request:
    get_session=1041ms, compact=1717ms, redact=31ms, json_write=8ms
- compact stage iterates all messages for user_message_count and runs
  recursive redact_session_data + json.dumps -- biggest single cost

Ref: perf/session-load-latency
2026-07-06 15:21:12 +00:00
Konstantin M
1018732a26 perf(webui): Phase 0 instrumentation for session-load hot path
The slow-request journal showed /api/profiles, /api/models, and /api/session
fire on every session click but had no per-stage timing. Add:

- RequestDiagnostics coverage for GET /api/profiles, /api/models, /api/session
- Stage markers inside /api/profiles (list / active lookup / isolated check)
- Stage markers inside /api/models (freshness routing, serialize)
- Per-stage stage-log inside get_available_models_for_session_visit that
  emits a [SLOW] line when total wall time crosses HERMES_DEBUG_SLOW ms
  (default 500ms) so we can pinpoint which sub-step is blocking

No behavior change. Existing _t0.._t6 timing on /api/session and the
2.5s/10s TTL on /api/sessions are unchanged. Reversible: revert commit.
2026-07-06 14:23:34 +00:00
Rod Boev
624e03c60a refactor(#2695): centralize WebUI static asset resolution 2026-07-06 06:45:36 -04:00
Rod Boev
3e53ef505c Preserve the RFC source anchor while sharing parser logic 2026-07-06 06:26:53 -04:00
Rod Boev
54a6292954 Share the run-journal event cursor parser 2026-07-06 06:23:03 -04:00
Rod Boev
3439bb8a46 Keep session stream source guards pointed at the live handlers 2026-07-06 06:14:03 -04:00
Rod Boev
aba74c762d Keep session SSE recovery tied to the live handler contract 2026-07-06 06:08:21 -04:00
Rod Boev
4bc405d708 Keep cursorless active SSE backlog from disappearing 2026-07-06 05:42:03 -04:00
Rod Boev
720e4191ad feat(#4812): add resumable session SSE stream 2026-07-06 05:16:18 -04:00
Rod Boev
80ee386827 Preserve lineage authority through serializer and refresh pass 2026-07-06 05:08:23 -04:00
Rod Boev
409be9bcd4 fix(#5598): stabilize lineage grouping across live refreshes 2026-07-06 04:49:24 -04:00
Frank Song
18a2078ebb fix(streaming): guard empty terminal error hints 2026-07-06 11:15:31 +08:00
nesquena-hermes
f7fe895c63 review: merge #5645 (00a69521) 2026-07-06 02:09:22 +00:00
nesquena-hermes
c601e11324 release: fork read-only cron sessions into a new writable chat (#5555)
Relaxes the /api/session/branch read-only gate so a canonical-cron session can be forked into a new WebUI-owned session (source never saved). Gate hardening folded in: the read-only branch gate now covers BOTH the synthesized/missing-sidecar path AND the persisted/loaded path (Codex found a stored read-only non-cron session could be branched + saved); relaxation keys on the server-authoritative resolved source (source_tag/raw_source/source == 'cron'), never the id prefix. Full gate: Codex adversarial SAFE, 84 branch/claim tests + suite 12175/0, browser clean.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-07-06 02:02:52 +00:00
nesquena-hermes
f10cb2a35c review: merge #5555 (11eed2af) 2026-07-06 01:44:21 +00:00
Rod Boev
29e9a27299 fix(#5619): preserve same-path cfg overrides on reload 2026-07-05 17:08:20 -04:00
Rod Boev
1124fd882a fix(#5619): reload model payloads on profile switch 2026-07-05 16:44:33 -04:00
Rod Boev
2f815f040c fix(#5619): clear stale cfg overrides on profile switch 2026-07-05 16:33:01 -04:00
Rod Boev
4c3a4e011e fix(#5619): reload config cache on profile switch 2026-07-05 16:22:35 -04:00
Frank Song
322e5a44fa Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 17:23:05 +08:00
nesquena-hermes
1165bd9ca2 fix(#5626): ignore inactive compacted state-db rows in context replay
get_state_db_session_messages() returned archived (active=0) rows that in-place
compaction had marked inactive. WebUI reconciliation feeds this reader straight
into the next model-facing context (reconciled_state_db_messages_for_session,
prefer_context=True), so those archived rows got pulled back in — resurrecting
pre-compaction history and making every later turn re-trigger compression. Now,
WHEN the messages table exposes an `active` column, inactive rows are excluded by
default (`AND (active IS NULL OR active != 0)` — NULL/legacy rows preserved), with
an `include_inactive=True` escape hatch for explicit recovery/audit callers.
Schemas without the column are unaffected (guarded by `'active' in available`).

Contributor stage; ungated on arrival. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-05 08:59:21 +00:00
Frank Song
0b339a0e8a Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 16:22:50 +08:00
nesquena-hermes
dbc2345fea fix(#5571): keep fork children out of compression stitch
_webui_sidecar_lineage_messages_for_display stitches a session's pre-compression
snapshot parents for full-transcript display. A cleared fork child could resurrect
its ORIGINAL (non-fork) parent's transcript when that parent was later compressed.
Now, when the root session is itself a fork (session_source=='fork'), the stitch
walk allows fork-sourced snapshot parents but stops at the first non-fork
ancestor — so a compressed fork continuation still shows its own pre-compression
turns, while an ordinary fork child stays isolated from the original parent.
Additive guard (one extra break condition); non-fork lineage display unchanged.

Contributor stage; gate-pass. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-05 08:07:38 +00:00
Frank Song
94305093d8 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 13:24:10 +08:00
nesquena-hermes
14ad45bd1f fix(#5311): skip live /v1/models probe for opencode-go — use curated static list
OpenCode Go's /v1/models endpoint returns models from the full public catalog that
are NOT enabled on the Go tier; selecting any such probe-only model 404s
('model not found') when the chat request is sent. get_available_models now skips
the live probe for opencode-go (bare branch) so the next `if not raw_models` falls
through to the curated static _PROVIDER_MODELS['opencode-go'] list — the actual
Go-tier models. Narrow, provider-scoped; no other provider affected.

Contributor stage; gate-pass. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-07-05 05:05:30 +00:00
Frank Song
fa5d4b7b7a Narrow compression recovery profile match fallback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 11:04:00 +08:00
Frank Song
4412e7a2f4 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 11:04:00 +08:00
nesquena-hermes
8da96a84d3 refactor(#5534): move CORS preflight header logic into api/routes (keep server.py thin)
The initial fix added ~16 lines to server.py, tripping test_server_py_under_750_lines
(server.py must stay a thin dispatcher; logic belongs in api/). Moved the whole
preflight-response into apply_cors_preflight_headers(handler) in api/routes.py;
server.py's do_OPTIONS is now a 4-line dispatcher. server.py 764 -> 746 lines.
Same security behavior (same-origin/allowlist echo, Vary: Origin, no wildcard,
header-less denial); test rewritten to the header-emitting API + added Vary and
no-header-on-denial assertions and a routes.py wildcard regression guard.
2026-07-05 02:35:10 +00:00
Frank Song
b3899d64c5 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 10:23:15 +08:00
nesquena-hermes
4e8978f04c fix(security): CORS preflight echoes only same-origin or allowlisted origins
do_OPTIONS answered every CORS preflight with Access-Control-Allow-Origin: *,
advertising broader cross-origin access than a real request is granted — on a
password-less deployment that lets any site read authenticated responses. It now
echoes the request Origin only when _check_same_origin_browser_request approves
it (the exact same-origin / HERMES_WEBUI_ALLOWED_ORIGINS policy the CSRF gate
enforces for real requests), adds Vary: Origin, and never emits *. A disallowed
origin gets a 200 with no CORS headers (browser treats as preflight denial).
Default deployments (no allowlist) are unaffected.

Contributor stage; gate-pass. Co-authored-by: mo7al876any <mo7al876any@users.noreply.github.com>
2026-07-05 02:18:51 +00:00
nesquena-hermes
bb818a56ba fix(#5570): also suppress restore when post-clear messages exist (gate fix)
Codex gate CORE: the exact-empty sentinel only suppressed restore for a bit-empty
cleared sidecar. If the user cleared, then sent ONE post-clear message, and the
stale pre-clear .json.bak still existed (unlink failed), recovery saw
bak_count>live_count and RESTORED the pre-clear transcript on top of the new
message. Added _live_supersedes_backup_by_clear_generation: suppress restore when
the live sidecar carries a clear_generation the backup lacks AND has a non-empty
transcript AND still shows the clear boundary reset (watermark==boundary==0.0).
Scoped to non-empty live so empty clear-shaped sidecars keep their existing
recovery semantics (active/pending/partial variants still restore). Fails open on
unreadable/partial reads. +2 regression tests (post-clear-message not restored;
post-clear-after-real-compaction still restores).
2026-07-05 02:10:30 +00:00
nesquena-hermes
8a6937978c fix(#5570): skip clear sentinel backups during recovery
Closes the crash-window resurrection corner in the #5532 clear-conversation
data-loss fix. /api/session/clear writes a .json.bak before truncating, and
because the cleared sidecar's truncation_watermark is 0.0, startup recovery saw
bak_count > live_count and could restore the backup — resurrecting the cleared
transcript after a crash/restart in the clear window.

Fix: stamp a unique per-clear `clear_generation` UUID on the sidecar when
clearing a session that had messages (persisted via Session model). Recovery's
_session_records_clear_sentinel() returns no_action when the live sidecar carries
a clear_generation the backup doesn't share AND the live sidecar matches the
exact cleared-empty shape (messages/context empty, watermark/boundary 0.0,
pending fields cleared). Same-generation backups stay recoverable; unreadable or
partial matches FAIL OPEN (normal recovery), so a real crash-loss is never
suppressed.

Contributor stage; gate-pass. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-05 02:10:30 +00:00
Frank Song
9d042102ae Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 09:42:11 +08:00
nesquena-hermes
b9e8ade4b6 fix(#5587): keep active CLI sessions visible before final output
Self-built stage of contributor PR; gate-pass.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-05 01:25:50 +00:00
Frank Song
440f1e6eb8 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 09:22:00 +08:00
nesquena-hermes
73d4b2b227 fix(#5575): preserve completed assistant replies during settle
The streaming settlement path could show a misleading "No response from provider"
error card AFTER a completed answer, because a stale terminal/partial result flag
still forced the generic no_response error path even when the merged current turn
already had a final assistant answer. Now the generic no_response terminal error
is suppressed ONLY when the already-merged current turn has a completed assistant
reply (scoped to soft `partial` results per the issue-thread recommendation),
letting normal `done` settlement continue. Real silent failures, partial
failures, auth/quota errors, cancellation, replayed rows, and terminal
tool/compression cases all keep their existing error behavior.

Self-built stage of contributor PR; gate-pass. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-05 01:15:09 +00:00
Frank Song
a6741f1c6f Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 08:42:15 +08:00
nesquena-hermes
93f447c7af fix(#3929 partial): classify credential-pool exhaustion with an actionable hint
'All 0 credential(s) exhausted for <provider>' did not match _is_quota_error_text
so it fell through to a generic 'Error' with no hint (the exception path's else
branch discarded the hint entirely). Adds a distinct credential_pool_empty
classification (ordered before the quota check) + an explicit exception-path
branch + an actionable pool-specific hint, so the user sees why the turn failed
and what to do. Does not over-match (doesn't swallow real quota errors) and
doesn't block partial-harvest; downstream tolerates the new type.

NOTE: this is the CLASSIFICATION half of #3929 only — the data-loss half
(reasoning/partial-work restore on reload-reconcile) is a scoped follow-up that
needs the reporter's server logs.

Self-built (nesquena-hermes); nesquena independent review APPROVED.
2026-07-05 00:35:26 +00:00
Frank Song
3b99b1f5fa Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 08:23:07 +08:00
nesquena-hermes
e09e819f08 fix(#5578): stop login next= param from nesting into itself and exploding the URL
An expired-auth 401 on the login page fed the login redirect its own address:
each of the three redirect guards (_safeNextPath in login.js,
_safe_login_redirect_path in routes.py, the api() 401 redirect in workspace.js,
_safe_login_inner_next in auth.py) validated against open-redirect but none
rejected a `next` pointing back at the login page, so every auth bounce wrapped +
re-encoded the previous login URL one level deeper — growing the URL exponentially
until the browser's length limit broke the tab. All guards now collapse/reject a
`next` whose decoded leading PATH resolves to the login route (bounded
percent-decoding up to 8 levels, fail-closed at the cap), while preserving the
existing open-redirect protections AND legitimate non-login paths that merely
carry their own `next=` query key. Length cap as belt-and-suspenders.

Self-built (nesquena-hermes); nesquena independent review APPROVED. Gate (Codex)
found + fixed an over-broad nested-next reject (regressed next-carrying paths) and
a deep-encode decode off-by-one; both root-fixed, login guards verified clean.
2026-07-05 00:12:19 +00:00
Frank Song
6a35d9c674 fix: preserve compression recovery source metadata
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 07:51:26 +08:00
Frank Song
81d5e897e9 fix: isolate compression recovery child sessions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 07:46:43 +08:00
Frank Song
0f5da09715 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 07:42:16 +08:00
nesquena-hermes
019a4814b7 fix(#5567): pin worker profile via hermes-agent v0.18.0 context-local HERMES_HOME override
profile_env_for_background_worker mirrored the profile home into process-global
os.environ["HERMES_HOME"] and yielded the worker body OUTSIDE the setup lock, so a
concurrent cross-profile worker (e.g. background title generation on another
profile) could clobber os.environ mid-body — the agent config reader then resolved
the WRONG profile's config (intermittent turn-init failures citing an unused
provider; b3nw's v0.51.849 repro). Now installs hermes-agent v0.18.0's
context-local home override (set_hermes_home_override, a ContextVar that
get_hermes_home() consults before os.environ) for the resolved non-default profile
home — set before yield, reset in finally — so a config read in the worker body
resolves THIS profile's home from task-local state, immune to a concurrent
os.environ clobber. No worker serialization, no os.environ mutation, no detection
heuristic (both earlier WebUI-only mitigations were rejected as regression-unsafe).

Graceful degradation: the override module is resolved lazily+optionally
(_resolve_hermes_home_override, mirroring _resolve_secret_scope_module). On agents
< v0.18.0 the symbol is absent -> returns None -> pre-existing os.environ-mirror
behavior unchanged (no regression, no new failure mode).

Self-built (nesquena-hermes); nesquena independent review APPROVED.
2026-07-04 23:40:10 +00:00
nesquena-hermes
8e954a423d fix(#5564): mint stable ids BEFORE dedupe at all commit sites (stale-boundary path)
Codex round-3 gate finding: _dedupe_replayed_context_messages deep-copies the
stale-user repaired boundary row (streaming.py:4501) into the context array. When
the mint ran AFTER dedupe, that deep-copied context row was no longer the shared
result dict, so it stayed id-less while the display copy got the minted id —
re-opening the cross-array id divergence for the stale-repair path. Fix: move
_assign_stable_message_ids() to run immediately after _restore_reasoning_metadata
and BEFORE _dedupe_replayed_context_messages at all four commit sites (streaming
main / in-band self-heal / except-path self-heal + routes runs/MoA), so the shared
result rows are stamped before any deep-copy — both arrays inherit the id.
2026-07-04 23:15:41 +00:00
nesquena-hermes
a8568749a1 fix(#5564): carry minted id onto eager-save checkpoint row in display merge
Codex/Opus gate finding: in eager session-save mode the current user turn is
checkpointed into s.messages before the agent runs (no id yet); after
_assign_stable_message_ids stamps the returned result row, the display merge
keeps the durable eager checkpoint and skips the stamped result — leaving the
display row id-less while its context twin carries the minted id, silently
defeating id-based fork/truncate alignment for eager-mode users. The merge now
copies the minted id onto the kept checkpoint when it lacks one (guarded so it
can't overwrite or duplicate). Opus verified cross-array id pairing holds across
the real 3-turn transform pipeline.
2026-07-04 23:06:21 +00:00
nesquena-hermes
ee93425b26 fix(#5564): mint stable per-message ids so large-session forks align
WebUI keeps two parallel arrays per session: messages (display transcript) and
context_messages (what's sent to the model). In large/compacted sessions they
diverge. Forking / in-place truncation copies a prefix of both and relies on
truncate_context_for_display_keep() to translate a display keep-index into the
matching context index — an aligner that prefers a stable per-row `id`. But the
model-context rows carried neither id nor timestamp (a 989-session live scan
found 0 with any id), so alignment fell back to fragile content-signature
matching that goes ambiguous on repeated tool calls / empty assistant turns.

This closes the DATA gap (the alignment logic itself already prefers `id` and
handles the compacted case via the matcher, shipped in #5563): mint a monotonic,
session-unique integer `id` on the per-turn result rows AFTER the context restore
and BEFORE both arrays are built, so the display and model-context copies of a
logical row share the same id.

- api/streaming.py: new _assign_stable_message_ids(); _restore_reasoning_metadata
  carries `id` forward across turns (as it already does timestamp); wired into all
  three streaming commit sites (main turn, retry, self-heal).
- api/routes.py: same mint on the runs/MoA _handle_chat_sync commit path.
- api/gateway_chat.py: mints ids on the two new rows of a gateway turn.
- The `id` is stripped before the provider API call (not in _API_SAFE_MSG_KEYS),
  same treatment as timestamp — nothing new reaches the provider.

session_ops.py alignment was already delivered by #5563 (id-preferring matcher +
compacted-case fall-through), so no change is needed there now; tests updated to
assert the post-#5563 behavior (id-bearing = exact cut; id-less = errs toward
under-keeping, never the old raw-index mis-cut).

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-04 22:46:56 +00:00
Frank Song
7425dc3549 fix: preserve compression recovery child markers in sidecar rebuild
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 06:45:50 +08:00
Frank Song
c7123bd5c9 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 05:46:56 +08:00
nesquena-hermes
19d00e3586 fix(#5543): fire-and-forget commit_session_memory + drain-safe managed stop
Move the 1-5s commit_session_memory() extraction off the POST /api/session/new
request thread (was blocking + New Chat) into a fire-and-forget daemon thread,
guarded by the existing per-session in-flight serialization.

Data-loss prevention on managed stop:
- server.py installs a SIGTERM handler that requests an orderly stop so
  serve_forever()'s finally block runs drain_all_on_shutdown() (the default
  SIGTERM disposition terminated without unwinding, losing in-flight commits).
- session_lifecycle: bounded self-unregistering background-commit registry;
  a _draining flag so no NEW worker starts once the drain begins (the inline
  generation-drain commits any late arrival instead); a 30s overall drain
  deadline so a stuck worker cannot hang the stop path forever.

Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
2026-07-04 21:40:21 +00:00
Frank Song
f0bfa74898 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 05:22:50 +08:00
nesquena-hermes
322c9fdb62 fix(#5563): align forked context to display for large (compacted) sessions
Fork ("Fork from here") on a large/compacted session could cut context off a
real turn boundary — slicing mid-turn or leaving a dangling tool call, feeding
a malformed context to the model. Align the fork cut to a resolved turn boundary
via a signature matcher handling both compacted and non-compacted display
coordinate spaces; errs toward under-keeping with send-time sanitization backstop.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-04 20:55:06 +00:00
Rod Boev
11eed2af54 fix(session): harden cron branch source gate (#5477) 2026-07-04 16:41:15 -04:00
Rod Boev
cbc164de0c feat(#5477): fork read-only cron sessions 2026-07-04 16:38:27 -04:00
Frank Song
60bcda09ad Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 04:25:29 +08:00
nesquena-hermes
81e89ec7b2 release #5532: clear-conversation P0 data-loss (combined, rebuilt on de-brittled base)
Rebuilt the combined #5556+#5553 fix on v0.51.859 (now has the flake fix + the
#5542 RFC de-brittle, so no anchor-test collateral). Nathan's call: rodboev's
#5556 primary + fold self-built #5553 improvements, credit both.

Clear handler now:
- routes through shared truncate_session_at_keep(s,0) (single source of truth,
  sets watermark=_truncation_watermark_for([])==0.0, the #2914 sentinel that
  blocks state.db append-merge replay)
- detaches compression lineage ONLY when the parent is a pre_compression_snapshot
  (Codex-caught: preserve genuine fork parent links for nesting + "Forked from")
- rodboev's persisted-clear read-back verification + stale .bak removal

RFC conflict resolved in favor of the de-brittled (symbol-anchor) master version.
Ships both PRs' test files (state_db_replay + clear_truncation_watermark incl the
fork-preservation regression). Opus (on the amended tree): both resurrection paths
closed, detach lineage-safe, verification sound — SHIP. 3 non-blocking follow-ups
filed (#5570 .bak crash-window, #5571 fork-stitch corner, #5572 messaging clear).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-04 19:58:38 +00:00
Rod Boev
ea2dc31c09 fix(#5532): prevent clear from replaying state.db history 2026-07-04 15:08:26 -04:00
Frank Song
f25571342e Keep recovery metadata after branch parent field
Restore the compact() source-order expectation used by the branch metadata contract test while keeping focused compression recovery markers in the compact payload.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-04 22:36:19 +08:00
Frank Song
1e9d42c053 Live Stream: harden compression recovery action
Make focused compression-recovery continuation creation idempotent by marking child sessions and reusing the existing child on repeated start requests. Also surface malformed successful recovery responses in the UI instead of silently re-enabling the action.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-04 22:30:57 +08:00
Frank Song
dfc0686dbd Live Stream: add compression recovery action 2026-07-04 21:42:32 +08:00
nesquena-hermes
f65598113d Merge commit '2c03277a9738' into release/stage-5522 2026-07-04 08:06:47 +00:00
allenliang2022
53cbfe23c6 fix(webui): resolve git.exe via Windows registry/known paths so WEBUI_VERSION doesn't degrade to 'unknown'
When the WebUI server is launched from a venv python whose PATH does not
include git (e.g. hermes-agent venv on Windows), shutil.which('git')
returns None and WEBUI_VERSION falls back to 'unknown'. That freezes the
?v=<stamp> static-asset cache key, so browsers keep serving stale cached
ui.js/messages.js even after the server is restarted with fixed code —
frontend fixes silently never reach clients (recurring scroll jump-back).

Add a Windows fallback to _resolve_git_executable(): read the
Git-for-Windows registry InstallPath (HKLM + WOW6432Node + HKCU), then
probe common install dirs. Mirrors the existing darwin /usr/bin/git
fallback. No behavior change on posix or when git is already on PATH.
2026-07-04 15:36:59 +08:00
nesquena-hermes
d27ef5d196 harden #5511: active provider owns its OWN providers: allowlist (complete ownership model)
Codex 5th CORE: the ownership set only covered static _PROVIDER_MODELS, not the
active provider's own config.yaml providers.<active>.models allowlist. So an
active provider defined purely via providers: (no static catalog) could be
hijacked by another slug's overlapping entry earlier in config order. Fix folds
the active provider's own providers: allowlist (list + dict shapes, copilot
excluded) into _provider_models_set — completing the ownership model so the
active provider owns its declared models from EITHER the static catalog OR its
own providers: entry. Regression test (fail-without-fix verified). 40 model_resolver
tests green.

Co-authored-by: akay64 <akay64@users.noreply.github.com>
2026-07-04 07:28:44 +00:00
nesquena-hermes
f2604a22bd harden #5511: _canonicalise_provider_id accepts alias targets in _PROVIDER_MODELS (Gemini class)
Codex 4th CORE (root latent bug in the shared canonicalizer): _canonicalise_provider_id
only accepted an alias resolution if the target was in _PROVIDER_DISPLAY. But some
canonical targets (e.g. 'gemini') are indexed in _PROVIDER_MODELS and NOT
_PROVIDER_DISPLAY, so 'google-gemini'->'gemini' was rejected and returned raw
'google-gemini' — leaving the ownership guard empty and letting providers.openai.models
hijack an active Gemini-owned model. Fix mirrors the direct-hit check (line 1445):
accept alias targets in EITHER _PROVIDER_DISPLAY OR _PROVIDER_MODELS. This is the
root fix for the whole alias-canonicalization class (gemini/google-gemini/
google-ai-studio etc.), not just the reported site. Blast-radius verified: 81
canonicalise/provider-alias tests across 5 files still green; x-ai still stays x-ai,
opencode_go still folds. Gemini regression test added (fail-without-fix verified).

Co-authored-by: akay64 <akay64@users.noreply.github.com>
2026-07-04 07:23:45 +00:00
nesquena-hermes
4399ff02e0 harden #5511: canonicalise active provider for ownership set (latent alias bug)
Codex 3rd CORE (pre-existing latent bug the providers: scan exposes): the
_provider_models_set ownership lookup used the RAW config_provider (e.g. 'z-ai')
but _PROVIDER_MODELS is keyed by canonical slug ('zai'), so an aliased active
provider got an empty ownership set -> _skip_custom_providers guard-2 silently
failed -> another providers.<slug>.models entry could hijack an active-owned
model (verified: active z-ai + glm-5 + providers.openai.models.glm-5 -> openai).
Fix: canonicalise config_provider before the _PROVIDER_MODELS lookup (and reuse
the canonical slug for the scan's ownership guard). Regression test with an
aliased active provider (verified fail-without-fix).

This is the root-cause fix for the alias class — the ownership guard now holds
for every aliased provider (zai/kilo/etc.), not just the reported site.

Co-authored-by: akay64 <akay64@users.noreply.github.com>
2026-07-04 07:17:05 +00:00
nesquena-hermes
f8c9f9e08c harden #5511: providers: scan honors active-provider ownership guard
Codex 2nd CORE: the new providers: scan didn't honor the _skip_custom_providers
active/default ownership guard (config.py:2535) that the custom_providers scan
uses. So with an active provider (e.g. ai-gateway) + model.default=gpt-5 AND a
providers.openai.models.gpt-5 entry, the scan hijacked gpt-5 to openai instead of
the active ai-gateway path. Fix: when _skip_custom_providers is true, the
providers: scan only considers the active provider's own canonical slug (skip all
other slugs). Regression tests: active-provider-owns-default not hijacked (fails
without fix); active provider's OWN providers: entry still matches.

Co-authored-by: akay64 <akay64@users.noreply.github.com>
2026-07-04 07:10:47 +00:00
nesquena-hermes
cca3b6c279 release #5511: exclude Copilot from providers: routing scan + regression tests + CHANGELOG
Gate-cert (nesquena-hermes) + Codex found 1 CORE: akay64's new providers: scan in
resolve_model_provider() iterated ALL providers.<slug>.models INCLUDING
providers.copilot.models — but that key is a documented per-model SETTINGS map
(reasoning_effort/limits), NOT a routing allowlist (config.py exception). So a
Copilot per-model settings entry hijacked that model's routing to Copilot
(Codex reproduced gpt-5 -> copilot). Fix (Codex-exact): skip copilot in the scan.

Added the regression tests the PR shipped 0 of: user-defined allowlist routes;
active-openai + providers.copilot.models (dict + list shapes) stays openai
(both verified to fail without the exclusion).

Co-authored-by: akay64 <akay64@users.noreply.github.com>
2026-07-04 07:06:05 +00:00
nesquena-hermes
ec9f77c319 Merge commit '87404d76f036' into release/stage-5511 2026-07-04 06:57:54 +00:00
nesquena-hermes
6c88c0360c harden #5504: dedup audit tombstone item when .bak + state.db row both survive
Codex v4 SILENT finding: my audit Finding-2 fix made the orphan-.bak branch emit
a state_db_deleted_webui_tombstone item, but the state_db_missing_rows loop still
emitted the same sid/kind again when both a surviving .json.bak AND a state.db row
exist for one deleted session — double-counting unsafe_to_repair, violating the
no-double-classification invariant. Fix: track _bak_tombstoned_ids from the .bak
branch and skip them in the state.db loop. Regression test
test_audit_no_double_count_when_bak_and_state_db_row_both_survive (verified fails
without the dedup).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-04 05:38:49 +00:00
nesquena-hermes
63d0c00f8d harden #5504 on rodboev's latest head: complete the tombstone wiring + CHANGELOG
@rodboev independently pushed fixes for messaging-tombstone (matches ours) and an
audit orphan-.bak skip — but the audit skip used _marks_deleted_webui_session
(index heuristic), which re-introduces the crash-suppression bug, and the other 4
resurrection/recovery paths were still open. Rebuilt the stage on rodboev's new
head (fc21c3f3, attribution preserved) and applied the complete superset:

- routes.py claim path: tombstone 404 gated on _state_db_session_source in
  ('','webui','fork') so foreign-source rows always materialize (finding B)
- models.py sidebar projection: skip tombstoned source='webui' rows w/ no live
  sidecar so a deleted session can't resurface in /api/sessions (finding C)
- session_recovery.py orphan-.bak startup: skip restore when durable tombstone
  present, durable-only (finding D)
- session_recovery.py audit orphan-.bak: durable-only classify as
  deleted_session_skipped (corrects rodboev's index-heuristic version)
- session_recovery.py _read_state_db_missing_sidecar_rows: durable-tombstone-only
  so a genuine crash (index intact, no durable tombstone) still repairs (finding F)

Regression tests (all verified fail-without-fix): sidebar-projection, orphan-.bak
startup skip, messaging-not-tombstoned, index-only-crash-repairable. Both advisors
(Codex SAFE TO SHIP + Opus ship-with-fixes) cleared the prior stage; this rebuild
carries the same fixes onto the contributor's latest work.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-04 05:32:51 +00:00
nesquena-hermes
06217c93b8 Merge commit 'fc21c3f38e56' into release/stage-5504 2026-07-04 05:25:04 +00:00
akay64
87404d76f0 fix: scan providers: in resolve_model_provider() for user-defined provider matching
Mirrors the existing custom_providers: scan immediately above. User-defined
providers declared under config.yaml's providers: section with explicit
models: lists were invisible to model-to-provider resolution — bare model
IDs fell through to the stale config_provider, routing through the wrong
endpoint.

The insertion site sits after custom_providers: (unchanged) and before
all heuristics (@provider:model, slash, fallback — all unchanged).
2026-07-04 08:59:21 +04:00
Rod Boev
fc21c3f38e fix(#5498): keep tombstoned backups out of repair audit 2026-07-04 00:53:03 -04:00
Rod Boev
c17ed6371e fix(#5498): preserve messaging sessions outside WebUI tombstones 2026-07-04 00:42:07 -04:00
nesquena-hermes
fe30f1ff00 Merge PR #5458 from rumotoshino: prefer IPv4 for extension gallery downloads 2026-07-04 03:39:19 +00:00
Rod Boev
308041c269 fix(#5498): remove unreachable tombstone recovery branch 2026-07-03 23:36:10 -04:00
Rod Boev
433f2a860e fix(#5498): skip durable delete tombstones in recovery audit 2026-07-03 23:27:48 -04:00
Rod Boev
9e13ec9e03 fix(#5498): prevent deleted WebUI sessions from resurrecting from state.db 2026-07-03 23:05:44 -04:00
nesquena-hermes
8fe2d63ec0 Merge remote-tracking branch 'origin/master' into release/stage-p1c
# Conflicts:
#	CHANGELOG.md
2026-07-03 20:44:31 +00:00
nesquena-hermes
5ea8b09b83 fix: prefer IPv4 for extension gallery downloads to prevent timeout on broken IPv6
Clean rebase of rumotoshino's #5458 (rebase-first).

Co-authored-by: rumotoshino <rumotoshino@users.noreply.github.com>
2026-07-03 20:26:18 +00:00
nesquena-hermes
797eb4a1b9 Merge PR #5464 from ai-ag2026: open session-listing projection read-only (#5455) 2026-07-03 20:16:35 +00:00
nesquena-hermes
8120e69e1d Merge PR #5442 from rodboev: persist TTS and voice preferences server-side (#5435) 2026-07-03 20:04:55 +00:00
nesquena-hermes
f2ab07757f ship-pass: #5453 overflow-path regression test + drop #5449 dead comment
- Add test_profile_model_selection_accepts_model_in_overflow_extra_models
  (the extra_models overflow-bucket regression test the warm dossier flagged
  as the #5453 ship-pass note).
- Drop the stray dead pseudo-code comment left in handle_post branch route
  (#5449 cosmetic nit).
2026-07-03 19:45:37 +00:00
nesquena-hermes
00e7203ec8 Merge PR #5449 from rodboev: hide read-only cron fork affordances (#5439) 2026-07-03 19:44:33 +00:00
TARS
10ecfbb376 fix: encode read-only session DB URI 2026-07-03 20:04:41 +02:00
TARS
855b7f1ea4 perf(sessions): open the session-listing projection read-only (#5455)
read_importable_agent_session_rows() is a pure read, but it opened a
read-write sqlite connection on the live multi-GB WAL state.db and re-ran a
defensive CREATE INDEX self-heal on every sidebar build. Holding a
write-capable handle while the agent streams into the same DB adds needless
checkpoint/lock surface.

Open the DB read-only (file:...?mode=ro) for the projection, and self-heal a
missing idx_messages_session through a separate short-lived writable
connection only. With the index present (the normal case) the read path
performs zero writes; when it is missing the self-heal still runs and rows
still come back.

Behavior-neutral: identical rows when the index exists; self-heal preserved
when missing; read-only-fs degradation to the pre-aggregated path unchanged.

Tests: tests/test_issue5455_listing_readonly_connection.py (read-only open,
no writable connection when index present, self-heal when missing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:41:49 +02:00
nesquena-hermes
654daf8f2a fix(profiles): include extra_models in profile model validation
Clean rebase of WallaceWebster's #5453 (rebase-first).

Co-authored-by: WallaceWebster <WallaceWebster@users.noreply.github.com>
2026-07-03 03:26:25 +00:00
Rod Boev
fdacb4b922 fix(#5435): stop startup workspace repair from persisting speech defaults 2026-07-02 21:28:46 -04:00
nesquena-hermes
0735a1b853 fix(#5439): hide read-only cron fork affordances
Clean rebase of rodboev's #5449 (rebase-first).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-03 00:58:30 +00:00
Rod Boev
8f7b9c1d0d fix(#5435): drop dead persisted-key state from config load 2026-07-02 19:38:35 -04:00
Rod Boev
3b344b1d9a fix(#5435): preserve local speech prefs until settings.json owns them 2026-07-02 19:33:13 -04:00
nesquena-hermes
2dcf7f529e Merge #5437: harden ElevenLabs TTS transport guards (#5430) [rodboev] 2026-07-02 21:35:42 +00:00
nesquena-hermes
8689c40995 Merge #5408: self-hosted provider setup (Ollama/LM Studio) in Settings (#3260) [rodboev] 2026-07-02 20:20:12 +00:00
Rod Boev
4993eed120 refactor(#5430): reuse shared TTS transport scaffolding 2026-07-02 15:43:42 -04:00
Rod Boev
91ae1dede9 fix(#5435): persist TTS and voice preferences server-side 2026-07-02 15:43:24 -04:00
Rod Boev
fc37919952 fix(#5430): harden ElevenLabs TTS transport guards 2026-07-02 15:10:01 -04:00
nesquena-hermes
bf1ca227dd fix(tts): block non-globally-routable addrs (CGNAT/Tailscale 100.64.0.0/10) via not-is_global backstop + tests (Opus follow-up on #5407) 2026-07-02 18:04:28 +00:00
nesquena-hermes
24eb618571 Merge #5407: pin OpenAI TTS proxy connect target vs DNS-rebind TOCTOU (#5291) [rodboev] 2026-07-02 17:45:04 +00:00
nesquena-hermes
b184588e8a Merge #5424: Bearer auth on remote /health/detailed probe (#5418) [nankingjing] 2026-07-02 17:07:51 +00:00
nesquena-hermes
32baf1302b fix(routes): unblock session/new after profile switch (#5420)
Clean rebase of nankingjing's #5423 (rebase-first).

Co-authored-by: nankingjing <nankingjing@users.noreply.github.com>
2026-07-02 16:34:37 +00:00
nesquena-hermes
4d7fad06e6 fix(health): send Bearer auth on remote /health/detailed probe (#5418)
Clean rebase of nankingjing's #5424 (rebase-first).

Co-authored-by: nankingjing <nankingjing@users.noreply.github.com>
2026-07-02 16:24:39 +00:00
nesquena-hermes
56889fa35e fix(#5291): pin OpenAI TTS proxy + iterate vetted RRset (DNS-rebind TOCTOU)
Clean rebase of rodboev's #5407 (rebase-first).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-02 14:38:09 +00:00
nesquena-hermes
7220747a1d feat(#3260): self-hosted provider setup (Ollama/LM Studio) in Settings (i18n complete)
Clean rebase of rodboev's #5408 (rebase-first).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-02 08:17:44 +00:00
nesquena-hermes
b931ae2f9b fix(settings): reconcile #5145 rename+steer-flip onto master's #5170 mirror
Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the
default from 'queue' to 'steer') onto current origin/master WITHOUT dropping
the shipped #5170 localStorage persistence mirror.

Rename the mirror machinery to the new setting name for consistency:
  _BUSY_INPUT_MODES        -> _DEFAULT_MESSAGE_MODES        (values unchanged)
  _normalizeBusyInputMode  -> _normalizeDefaultMessageMode  (fallback now 'steer')
  _persistBusyInputMode    -> _persistDefaultMessageMode
  _readPersistedBusyInputMode -> _readPersistedDefaultMessageMode
  window._busyInputMode    -> window._defaultMessageMode (+ renamed exports)

localStorage: write the new 'hermes-default-message-mode' key; read it with a
fallback to the legacy 'hermes-busy-input-mode' key so an existing user's
persisted preference survives the rename.

Preserve #5170 behavior at every mirror site under the new names:
  - boot success  -> window._defaultMessageMode=_persistDefaultMessageMode(...)
  - boot FAILURE  -> window._defaultMessageMode=_readPersistedDefaultMessageMode()
    (NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when
    the server is unreachable; do not regress #5167/#5132)
  - preferences autosave, settings-panel load, and _applySavedSettingsUi all
    persist through _persistDefaultMessageMode(...)

Tests updated for the rename while keeping the persistence-behavior assertions
(test_1062, test_5145, test_5167); test_5167 gains explicit guards that the
load-failure path reads the persisted pref and never hardcodes a literal mode,
plus autosave/panel-load mirror-write coverage.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-07-02 05:44:48 +00:00
Rod Boev
8e398cd1b3 fix(cron): stop auto-creating the Cron Jobs project without project opt-in (#5379) 2026-07-02 04:36:45 +00:00
nesquena-hermes
b31f3fab3b #4968: export chat to self-contained themed HTML, rebased on master 2026-07-02 02:36:09 +00:00
nesquena-hermes
4bbb64bcc1 #5213: Claude Code sidebar visibility toggle (#4714), rebased on master 2026-07-02 01:40:19 +00:00
nesquena-hermes
76d6cc7f74 #5142: office-doc preview + safe docx editing (#540), rebased on master; optional deps 2026-07-02 01:15:57 +00:00
nesquena-hermes
728caf5ec6 #4682: surface read-only other-profile cron jobs in Tasks panel (#3947), rebased on master 2026-07-02 00:44:24 +00:00
nesquena-hermes
b709ae6fc2 #5228: require browser provenance on all proxy methods (close GET/POST asymmetry from gate); CHANGELOG 2026-07-02 00:23:10 +00:00
nesquena-hermes
3d99763f3a #5228: opt-in extension loopback proxy (#4747), rebased on master; union urllib imports 2026-07-01 23:49:25 +00:00
nesquena-hermes
9fb2731848 #5310: push-to-talk hold gesture (#3700) 2026-07-01 23:26:15 +00:00
nesquena-hermes
36643867f4 #5153: MoA gateway fail-closed routing 2026-07-01 23:24:38 +00:00
nesquena-hermes
d43618beca stage #5301 2026-07-01 22:42:35 +00:00
nesquena-hermes
c191141615 fix #5368 2026-07-01 21:35:11 +00:00
nesquena-hermes
2574e6bef9 stage #5317 2026-07-01 21:10:21 +00:00
nesquena-hermes
4a3b820e22 stage #5370 2026-07-01 21:06:10 +00:00
Rod Boev
f3c01812b7 fix(#4251): drop dead post-repair ownership writes 2026-07-01 16:20:31 -04:00
Rod Boev
a609b99e76 fix(#4251): guard provider ownership under the session lock 2026-07-01 16:19:53 -04:00
b3nw
a477069e3a fix(model): address gate feedback #4857080754 — slug names, list models, get_config, single YAML parse
- _repair_bare_custom_provider_model matches display-named providers via
  _custom_provider_slug_from_name (custom:my-proxy matches 'My Proxy').
- _ordered_custom_provider_model_ids now handles dict keys, list strings,
  and list dicts with id/model/name, aligned with api/config.py catalog.
- config_obj=None fallback uses get_config() instead of raw cfg.
- _read_profile_model_config returns profile config dict too, avoiding a
  second YAML parse on hot display paths.
- Tests added for slug matching, list-form models, and get_config fallback.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 20:12:38 +00:00
Rod Boev
5c7ff9c70e fix(#4251): preserve raced picker ownership through profile repair 2026-07-01 15:42:57 -04:00
nesquena-hermes
ab38ffe25f Fix profile skills-stats thundering herd at cold startup (#5364)
The two-tier mtime cache from #4783 fixed the per-request SKILL.md rescan
but left two concurrency holes that only bite at container cold start,
when the frontend fires several profile-data requests at once and the
caches are empty:

1. `_get_profile_skills_stats()` had no lock, so concurrent misses on the
   same profile each ran `os.walk(followlinks=True)` + parsed every
   SKILL.md simultaneously.
2. `_build_profile_rows_fast()` ran outside `_LIST_PROFILES_CACHE_LOCK`
   in `list_profiles_api()`, so every concurrent request rebuilt all rows
   (each walking every profile's skill tree) at once.

With ThreadingHTTPServer (one OS thread per request) and Docker overlay2,
this stacked thousands of concurrent stat() calls and stalled workers
57-70s (per the report's thread dumps).

Fix:
- Add a per-profile compute lock (registry guarded by a meta-lock) and
  use double-checked locking in `_get_profile_skills_stats()`: concurrent
  misses on one profile collapse to a single compute, while independent
  profiles still compute in parallel.
- Single-flight the row build in `list_profiles_api()` by holding
  `_LIST_PROFILES_CACHE_LOCK` across the build + cache write. Lock order
  is strictly list-lock -> per-profile skills-lock, so no deadlock.

The report's third suggestion (debounce the mtime probe) is deliberately
NOT taken: the every-call cheap probe is the #4783 out-of-band
change-detection contract (test_issue4783 asserts it MUST run on every
call). Serializing the misses removes the herd without weakening that
contract, since only the expensive compute is guarded, not the probe.

Adds tests/test_issue5364_skills_stats_thundering_herd.py proving the
herd collapses (single compute / single build under a concurrent burst),
independent profiles still parallelize, and the every-call probe contract
is preserved. All existing #4783 contract tests still pass.

Co-authored-by: claw-io <claw-io@users.noreply.github.com>
2026-07-01 19:42:11 +00:00
nesquena-hermes
17b0998c0b stage #5313 2026-07-01 19:35:42 +00:00
Rod Boev
160a397c48 fix(#4251): stop in-flight turns from reverting picker model choice 2026-07-01 15:35:08 -04:00
nesquena-hermes
2de28f5e14 stage #5360 2026-07-01 19:08:24 +00:00
nesquena-hermes
dd9216c770 stage #5357 2026-07-01 18:46:44 +00:00
hermes-agent
741f8fed70 fix(webui): align reconciliation dedup key with workspace-prefix stripping (#5339)
_session_message_content_key (the state.db reconciliation key in
api/models.py) normalized whitespace only, while the streaming-side
identity _message_identity strips the workspace prefix for user turns.
WebUI sends the model a workspace-prefixed user_message
([Workspace::v1: /path]\n<text>) while the visible/optimistic bubble and
sidecar row carry the bare <text>. The mismatch made a prefixed state.db
row and a bare sidecar row key differently, so state_db_delta_after_context
failed to align them, treated the state.db copy as new, and appended a
duplicate user turn. The agent-side merge then concatenated the two
adjacent user rows into a permanent composite -- the post-restart
stale-user-prepend bug.

Fix: strip the workspace prefix for role=='user' in the reconciliation
key, reusing the same _strip_workspace_prefix helper the streaming side
uses (lazy import to avoid the api.streaming -> api.models cycle) so the
two dedup layers can't drift again. Assistant/tool keys are unchanged and
prefix-free user messages key identically (idempotent).

Fixes #5339.
2026-07-01 18:45:18 +00:00
nesquena-hermes
2693a75d89 stage #5354 2026-07-01 18:41:03 +00:00
nesquena-hermes
30d8edc5ea stage #5353 2026-07-01 18:36:29 +00:00
nesquena-hermes
6caaced8eb fix(webui): bound in-memory SESSIONS cache with lazy reload (#4765)
Root cause of the silent-crash-after-hours cluster (#4765/#2233/#4633): the
global in-memory SESSIONS LRU evicted with a blind popitem(last=False), which
could drop an actively streaming or not-yet-persisted session (data loss) and
was capped only via an env var. On long-running installs the effective result
was unbounded RAM growth until segfault.

- Add _session_is_evictable(): a session is evictable ONLY when it is not
  streaming (no active_stream_id), has no in-flight turn (no pending_user_message
  / pending_started_at), and its full state is proven on disk (sidecar
  message_count >= in-memory count; metadata-only stubs and zero-message shells
  are trivially safe).
- Add _evict_sessions_over_cap(): replaces all 8 blind popitem loops across
  models.py, routes.py, streaming.py. Walks the LRU oldest-first and removes only
  provably-safe entries; never acquires LOCK/stream locks itself (caller holds
  LOCK) so no lock-ordering deadlock. May briefly exceed the cap rather than ever
  evict an active/unsaved session.
- Make the cap configurable via config.yaml webui.sessions_cache_max
  (get_sessions_cache_max()); precedence config.yaml -> HERMES_WEBUI_SESSIONS_MAX
  (legacy) -> DEFAULT_SESSIONS_CACHE_MAX=300. No new HERMES_* env var. Invalid or
  <1 values fall back so a typo can never disable the bound.
- Evicted sessions lazily reload from their JSON sidecar via the existing
  get_session() accessor; no call sites changed. _index.json sidebar behavior
  unchanged (the sidebar reads the index, not SESSIONS).
- Add tests/test_issue4765_sessions_lru_eviction.py (8 tests): eviction past
  cap, active/streaming never evicted, unsaved/stale-tail never evicted, lazy
  reload with identical content, and no-data-loss under heavy churn.
- README: document the config.yaml key + safety semantics.

Fixes #4765.
2026-07-01 18:14:39 +00:00
nesquena-hermes
8b8ee92eca fix(webui): overlay real state.db message count for subagent children so they don't vanish from the sidebar (#5308)
Regression seam behind #5308: a delegated subagent child's sidebar row is built
from a stale sidecar that reports message_count==0, and the state.db count
overlay in _apply_sidebar_state_db_override_metadata was gated on
`state_db_source == 'webui'`. A subagent child (state_db_source=='subagent')
therefore never received its true message count, so the front-end visibility
predicate (_sidebarRowHasVisibleMessages) dropped the row and the subagent
session disappeared entirely (not nested, not orphaned) after #5244+#5306.

Fix: widen the count/last-message overlay to `state_db_source in ('webui','subagent')`,
keeping the same conservative anti-resurrection guard. The source-tag/title
reassignment stays WebUI-only so a subagent child keeps its subagent
classification. Same state.db-blind-metadata root as the #5307 transcript
recovery, fixed server-side rather than by loosening the front-end predicate
(which would fight the #5306 active-parent scoping).

Tests: subagent child gets its count overlaid + classification preserved; a
non-webui/non-subagent foreign source (cron) still gets NO overlay.

Fixes #5308.
2026-07-01 18:06:21 +00:00
hermes-agent
5bca5db900 fix(webui): crash visibility — faulthandler + thread excepthook + exit audit (#4633)
server.py exited SILENTLY after 9-16h: no traceback, no shutdown-audit line,
no core dump — the log just stopped mid-request. It runs a ThreadingHTTPServer
with daemon_threads=True, so an unhandled exception in a request/SSE/long-poll
handler thread could terminate work with nothing recorded, and faulthandler was
not enabled so a native crash left nothing at all. The WebUI also configures no
logging handlers, so INFO/ERROR records are dropped by logging's lastResort
filter (WARNING+ only) — meaning even the existing shutdown audit never reached
the log.

Add api/crash_visibility.py (stdlib-only, hooks never raise) and wire
install_crash_visibility() into server.main() before any heavy startup:
  * faulthandler.enable(all_threads=True) — native crash dumps a C-level
    traceback; SIGUSR1 registered for on-demand hang diagnosis.
  * threading.excepthook — logs uncaught daemon/handler-thread exceptions
    (thread name, ident, traceback) instead of losing them silently.
  * sys.excepthook — logs uncaught main-thread exceptions (KeyboardInterrupt
    preserved).
  * atexit exit-audit breadcrumb — a clean/unwound exit is recorded; its
    absence narrows a silent death to an un-unwound kill (OOM/SIGKILL/abort).

All diagnostics are written directly to the fault stream (stderr, which the
bootstrap redirects into the WebUI log) AND mirrored through logging, so the
line is guaranteed to land regardless of logging config.

No request-handling behavior change, no new deps. Paired memory root-cause: #4765.

Fixes #4633.
2026-07-01 18:01:39 +00:00
nesquena-hermes
86c9ec0a85 fix(webui): drop verification-stop synthetic nudge from transcript (#5334) 2026-07-01 18:00:28 +00:00
Charles Inglis
f9de93f42e refactor: make nested-route reasoning deny boundary-based, not prefix-based
Structural hardening per user request, following the 3rd round of the
same bypass class on PR #5313's reasoning_efforts feature:

Round 1: plain-ordering regression (provider-config short-circuit ran
before the nested-route deny).
Round 2: a provider-qualified hint (@custom:<slug>:vertex/gemini-...)
left a slug fragment that the deny's prefix-match missed.

Both were legitimate, narrowly-targeted fixes, but the pattern —
_nested_route_reasoning_denied() requiring the model string to START
WITH the route prefix — meant every future wrapper/nesting scheme
would need the strip logic to be updated in lockstep, and a missed
case fails OPEN (reasoning re-enabled on a route that must never show
it), which is the wrong failure mode for a security-adjacent guard.

This commit removes that class of bug instead of patching its latest
instance: _nested_route_reasoning_denied() now searches for the
vertex/gemini- or gemini_cli/gemini- pattern ANYWHERE in the string at
a non-alphanumeric boundary, rather than requiring it at position 0.
Correctness no longer depends on _strip_provider_hint_for_reasoning()
having stripped exactly the right prefix first — any number of opaque
wrapper layers (@provider:, a named custom-provider slug, or any
nesting scheme not yet invented) can precede the route and the deny
still fires.

Verified: all historical bypass strings (round 1 and round 2, plus a
hypothetical deeper double-wrapped case) now correctly deny; embedded
substrings that must NOT match (e.g. 'notvertex/gemini-x') correctly
don't, thanks to the boundary lookbehind.

Added test_nested_route_deny_is_boundary_based_not_prefix_based
locking in the structural invariant directly, independent of any
particular wrapper scheme.

Verification:
./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py
# 54 passed in 1.31s

Full suite: 11257 passed, 64 pre-existing failures (identical file/test
set as the prior commit's baseline — network/credential/profile-isolation/fd-leak
tests, unrelated to reasoning_efforts).
2026-07-01 10:02:45 -05:00
b3nw
1feb6f8280 fix(model): gate feedback — profile-scoped repair, malformed config (#5317)
Address nesquena-hermes gate certification on PR #5317:
- _repair_bare_custom_provider_model: coerce config values via str();
  use api.config._custom_provider_entries; optional config_obj for
  profile config.yaml (not process-global cfg).
- Thread profile_config from _load_profile_config_dict through session
  display, chat/start, wakeup, goal, and sync resolvers.
- Tests: malformed name=None entry; profile vs global collision.

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 14:35:56 +00:00
Charles Inglis
0109baa142 fix: strip named-provider slug before nested-route reasoning deny
Resolve gate certifier feedback on PR #5313 (deeper bypass, round 3):

A provider-qualified hint like `@custom:<slug>:vertex/gemini-image-1.0`
still bypassed `_nested_route_reasoning_denied()`. The old
`_strip_provider_hint_for_reasoning()` did a naive first-colon split,
which only removed the leading `@custom:` wrapper and left the named
provider's slug (e.g. `agg:vertex/gemini-image-1.0`) attached to the
model id. That leftover slug fragment no longer starts with
`vertex/gemini-`, so the nested-route deny missed it and a configured
`providers.<name>.reasoning_efforts` / `custom_providers[].reasoning_efforts`
allowlist re-enabled reasoning controls on Gemini image/embedding routes
that must never expose them.

Fix: `_strip_provider_hint_for_reasoning()` now accepts the resolved
provider id and strips the exact `@{provider}:` prefix first (e.g.
`@custom:agg:`), so both the wrapper and the slug are removed in one
pass before the nested-route deny check runs. Falls back to the
original first-colon split when no provider is supplied, preserving
existing behavior for plain `@provider:model` hints.

Verified in-process: both
`@custom:agg:vertex/gemini-image-1.0` and
`@custom:agg:vertex/gemini-embedding-001` now correctly resolve to []
instead of leaking the configured ["low", "high"].

Added regression test extending the existing nested-route-deny test to
cover provider-qualified hinted models.

Verification:
`./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py`
-> 53 passed in 1.23s
Full suite: 11258 passed (63 pre-existing failures unrelated to this
change — network/credential/profile-isolation/fd-leak tests; confirmed
identical failure set on unmodified branch).
2026-07-01 09:35:14 -05:00
promptclickrun
f838cc902a fix(webui): guard non-dict resolve_moa_preset result in resolve_moa_config
A hermes-agent build that returns a non-dict (e.g. None) for an unknown
preset without raising would make resolved.update(selected) raise TypeError,
which bypasses the routes.py except RuntimeError guard and surfaces as an
unhandled 500 on /api/chat/start. Coerce a non-dict result to {} so preset
resolution degrades cleanly. Adds a regression test.
2026-07-01 07:54:48 -05:00
Charles Inglis
4f4c8fd183 fix: respect custom provider config and nested route denies
Resolve review feedback on PR #5313:
- Read reasoning_efforts from named custom_providers entries for custom:<name>
  providers instead of looking only in providers:<name>.
- Preserve the nested-route deny ordering before the provider-config shortcut
  so Gemini image/embedding routes cannot be re-enabled by config.
- Deduplicate configured effort values before returning them.

Add focused regression tests for bare provider config, named custom provider
config, all-invalid fallthrough, ACP hard guards, and nested route denies.
2026-07-01 07:35:08 -05:00
Gordie
fb8bf2f5bd fix(webui): keep #1855 fast-path window and Copilot gpt-4o regression tests green
- Extract the MoA @moa:/moa/ prefix-stripping branch of
  _resolve_compatible_session_model_state into a new
  _moa_fast_path_model_state() helper. Inlining it had pushed
  `catalog = get_available_models()` just past the 6000-char
  source window that
  tests/test_issue1855_resolve_model_provider_fast_path.py::
  TestFastPathSourceShape scans to guard the #1855 fast-path/
  catalog-call ordering, breaking that regression test after
  rebasing onto current master.
- Re-add gpt-4o to the refreshed Copilot static fallback list.
  It's a real Copilot-served model and
  tests/test_issues_373_374_375.py::TestStaleModelListCleanup::
  test_copilot_list_unchanged asserts the Copilot list keeps it
  even as #374 removes it from the generic OpenAI list. The live
  Copilot catalog probe remains authoritative; this only affects
  the cold-start/probe-miss fallback.

Verification:
  ./scripts/test.sh tests/test_issue1855_resolve_model_provider_fast_path.py tests/test_issues_373_374_375.py tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py tests/test_pr1970_lmstudio_base_url_fallback.py -q
  -> 73 passed in 3.60s

  ./scripts/test.sh tests/ -k "config or provider or model or copilot or moa or lmstudio" -q
  -> 1745 passed, 2 skipped, 9775 deselected, 1 xpassed in 68.57s
2026-07-01 07:01:51 -05:00
Gordie
0b8e67c20d fix(#5301): admit active models-only custom provider without reopening dedup regression
Maintainer review found regression 2 on commit 575996a7: the new
_has_provider_route gate (api/config.py) required api/base_url/api_key/key_env
before admitting a configured provider into the picker, which dropped a
models-only custom provider config (the lmstudio-style shape from #1970,
tests/test_pr1970_lmstudio_base_url_fallback.py::test_provider_catalog_preserves_dict_shaped_raw_key_lookup).

A naive fix (admit any config with models) would re-break
test_unknown_duplicate_copilot_provider_config_is_not_rendered, since a
spurious alias like copilot-2: {name: copilot, models: {...}} must stay
rejected.

Fix: admit a models-only provider config as evidence only when its canonical
id matches the active/configured provider (threaded via active_provider,
already in scope). Non-active models-only configs (including duplicate
aliases of known providers) are still rejected.

Added a regression test:
tests/test_pr1970_lmstudio_base_url_fallback.py::test_provider_catalog_rejects_non_active_models_only_custom_provider
covering both the active-admits and non-active-rejects cases side by side.

Verification:
./scripts/test.sh tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py tests/test_pr1970_lmstudio_base_url_fallback.py -q
26 passed, 1 skipped in 5.58s

Broader sanity pass: ./scripts/test.sh tests/ -k "config or provider or model or copilot or moa or lmstudio" -q
1728 passed, 5 skipped, 9654 deselected (4 pre-existing failures confirmed unrelated: 2 reproduce identically on the pre-fix commit via git stash, 2 are order-dependent and pass in isolation).
2026-07-01 07:01:51 -05:00
nesquena-hermes
cf1ab5bbbc fix(#5301): scope models-as-settings-map guard to Copilot only (was breaking providers.<id>.models allowlist for all built-ins, #644 regression) 2026-07-01 07:01:51 -05:00
Gordie
07a1509feb fix(webui): harden MoA picker preset fallbacks
- Avoid an unguarded resolve_moa_preset fallback call when preset resolution fails.
- Populate the MoA picker directly from configured presets so older Hermes Agent installs that lack provider_model_ids('moa') still render the virtual provider.
- Add regression coverage for both review findings.

Verification:
./scripts/test.sh tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py -q
12 passed, 1 skipped
2026-07-01 07:01:51 -05:00
Gordie
f29441bf09 fix(webui): split MoA picker and Copilot catalog fixes
- Keep resolve_moa_preset optional so older hermes-agent installs still degrade gracefully.
- Surface MoA presets in the WebUI model picker as a virtual provider.
- Keep configured Copilot per-model settings from collapsing the built-in catalog.
- Refresh the Copilot static fallback used when the live catalog probe misses.

Verification:
./scripts/test.sh tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py -q
11 passed, 1 skipped
2026-07-01 07:01:51 -05:00
nanw
161e4173c2 fix(sessions): prune index-only ghost sessions (#5331)
Phase 2 of cleanup now sweeps _index.json for stale entries with no
backing file and no in-memory session, removing them regardless of
title (catches multi-language 'Untitled' variants). Phase 3 only
deletes the index when Phase 1 removed files AND Phase 2 couldn't
run, fixing the cache-busting on every cleanup call.

Fix two phase-interaction bugs flagged by Greptile review:
- Track phase1_removed_ids to prevent Phase 2 double-counting
  sessions already removed from disk by Phase 1.
- Track phase2_rewrote_index so Phase 3 skips deletion when
  Phase 2 already cleaned the index in-place.

Adds test_issue5331_index_only_ghost_cleanup.py with 12 test cases.
2026-07-01 17:41:31 +08:00
nesquena-hermes
b65ab14538 fix(#5307): gate /api/goal + /api/btw subagent write paths (Codex round 11) 2026-07-01 08:55:58 +00:00
nesquena-hermes
cdbd84585a fix(#5307): gate final 4 subagent write paths (Codex round 10)
_session_is_subagent_view_only guard added to the remaining paths that could
mutate a stale persisted subagent sidecar:
- _handle_handoff_summary (appends tool msg to state.db)
- _handle_chat_sync (fallback POST /api/chat)
- /api/session/draft POST (composer_draft save)
- /api/personality/set (per-session personality save)
Fixes #5307
2026-07-01 08:47:34 +00:00
nesquena-hermes
e6e7454c7a fix(#5307): gate ALL remaining session-mutation routes + stale-webui-row coercion (Codex round 9)
Enumerated every /api/session/<mutation> route and applied _session_is_subagent_view_only:
duplicate, branch, retry, undo, toolsets, compress, archive (existing-sidecar path)
now 400 for subagent children; delete/clear/truncate/pin already gated; rename/move/update
route through the gated _get_or_materialize_session (403). This closes the data-loss
paths (delete_cli_session, retry/undo truncate, branch/duplicate fork).

Also: /api/sessions list coercion now also honors state.db source='subagent' for rows
whose stale index says webui/fork, so they can't surface as writable/CLI sidebar rows.
Fixes #5307
2026-07-01 08:38:47 +00:00
nesquena-hermes
23743c5976 fix(#5307): guard direct mutation routes + coerce list rows (Codex round 8)
The root non-CLI classification surfaced subagent rows in the sidebar without
read_only, exposing delete/truncate/pin — and /api/session/delete calls
delete_cli_session() which erases the child's state.db transcript (data loss).

- /api/sessions list: coerce subagent rows to read_only=True + is_cli_session=False
  so the UI offers no mutation affordances.
- New _session_is_subagent_view_only() shared guard; applied to the direct
  mutation routes that bypass _get_or_materialize_session(): delete / clear /
  truncate / pin now 400 for subagent children. (rename/move already route
  through the gated _get_or_materialize_session and 403.)
- tests: guard helper + static contract that all 4 routes + list coercion present.
Fixes #5307
2026-07-01 08:21:03 +00:00
nesquena-hermes
9eb208ebc3 fix(#5307): root-fix subagent classification + GET serialization (Codex round 7)
- api/agent_sessions.py is_cli_session_row(): add 'subagent' to non_cli_sources
  so EVERY consumer (sidebar rows, /api/sessions, etc.) classifies a delegated
  child as non-CLI (the root the per-site fixes were compensating for).
- api/routes.py GET /api/session happy path: coerce is_cli_session=False +
  read_only=True in the serialized payload for a subagent child before redaction,
  so a stale writable sidecar can't be exposed as writable to the browser.
Fixes #5307
2026-07-01 08:10:24 +00:00
nesquena-hermes
2e33e40272 fix(#5307): guard persisted subagent sidecars against writable use (Codex round 6)
- _get_or_materialize_session happy path: reject an already-persisted subagent
  sidecar (source_tag/raw_source=='subagent' or state.db subagent) even when it
  was stored read_only=False (pre-fix materialization), so chat-start can't write it.
- import_cli existing-session refresh: coerce read_only=True on the persisted
  sidecar (and response) when it's a subagent child.
- test: persisted read_only=False subagent sidecar is still refused by the helper.
Fixes #5307
2026-07-01 08:04:47 +00:00
nesquena-hermes
6e2ead5b1b fix(#5307): close cross-profile + existing-session subagent edges (Codex round 5)
- import_cli _read_only_view now also treats resolved cli_meta source_tag/raw_source
  =='subagent' as view-only (all_profiles=true resolves cli_meta from a non-active
  profile, which the active-profile state.db _sa_child check could miss).
- import_cli existing-session refresh branch no longer hardcodes is_cli_session=True
  for a subagent child (both the persisted update and the response payload).
Fixes #5307
2026-07-01 07:55:00 +00:00
nesquena-hermes
161fdeba7a fix(#5307): gate the 3rd/final import_cli_session write path (archive fallback)
Codex round 4 found POST /api/session/archive's missing-sidecar fallback also
calls import_cli_session(). grep confirms exactly 3 import_cli_session() call
sites in routes.py; all 3 now refuse source='subagent' children:
  - 4792 _get_or_materialize_session (chat-start) -> PermissionError
  - 22382 import_cli endpoint -> read-only view payload
  - 13885 archive fallback -> 400 'Subagent sessions cannot be archived'
So no path can materialize a delegated child as a writable WebUI sidecar.
Fixes #5307
2026-07-01 07:48:34 +00:00
nesquena-hermes
c989acc991 fix(#5307): gate subagent children in the shared materialize chokepoint (Codex round 3)
Codex found a 3rd writable path: POST /api/chat/start -> _get_or_materialize_session()
materialized source='subagent' as a writable sidecar before the not_claimable guard.
Fix: refuse subagent children (PermissionError) at that shared chokepoint, checked via
_is_subagent_child_session_id(sid) (state.db source, independent of cli_meta) BEFORE the
materialize path — so all three entry points (GET synth, import_cli, chat-start) now
consistently keep a delegated child view-only. CLI/TUI/Desktop materialization preserved.

Tests: materialize helper refuses subagent child + still allows tui.
Fixes #5307
2026-07-01 07:44:11 +00:00
nesquena-hermes
3ecb2625aa fix(#5307): close 2 more subagent-child writable-session holes (Codex round 2)
- api/routes.py GET /api/session synthesized response: stop hardcoding
  is_cli_session=True; serialize bool(synth.is_cli_session) so a recovered
  subagent child stays not-CLI-classified (was overriding the helper's False).
- api/routes.py POST /api/session/import_cli: gate source='subagent' into the
  read-only view payload (is_cli_session=False, imported=False) BEFORE
  import_cli_session(), so a subagent child can never be materialized as a
  writable WebUI sidecar via this endpoint.
- tests: assert import_cli routes subagent children read-only (no materialize).

Both were paths that bypassed the _is_claimable_cli_source denylist. Fixes #5307
2026-07-01 07:35:48 +00:00
nesquena-hermes
a823ab5ff8 fix(#5307): recover subagent child transcript view-only (Codex hardening)
Reworked per the Codex gate finding: the first approach widened the client
import predicate, which (a) conflicted with #3603's intentional _isExternalSession
gate and (b) let import_cli persist the subagent child as a WRITABLE, CLI-classified
session that then passed the poll-skip/active-refresh gates.

Corrected to a server-side, view-only recovery:
- api/routes.py: mark source='subagent' as NON-claimable in _is_claimable_cli_source
  (both cli_meta and state.db source denylists). A subagent child now resolves via
  the not_claimable branch -> read-only Session with its state.db transcript, and
  build_session takes is_cli_flag (False for subagent children) so the recovered
  session is NOT CLI-classified and can't widen the frontend _isExternalSession gates.
- static/sessions.js: REVERTED to master (no client change needed; #3603 contract intact).
- tests: assert reason=not_claimable, read_only=True, is_cli_session!=True, transcript present;
  #2782 deleted-webui 404 preserved; #3603 _isExternalSession contract preserved.

58 tests green (5307 + 3603 + claim-cli + core-data-loss). Fixes #5307
2026-07-01 07:24:16 +00:00
nesquena-hermes
bed98c787c fix(sessions): load delegated subagent child transcript from state.db (#5307)
A delegated subagent child (source='subagent' in state.db) usually has no WebUI
sidecar but is registered in the WebUI index sharing the parent's lineage. That
made GET /api/session -> _claim_or_synthesize_cli_session return 'was_webui' ->
404, so the child pane opened empty despite state.db holding messages.

- api/routes.py: add _state_db_session_source() + _is_subagent_child_session_id();
  exclude subagent children from the was_webui 404 gate so they recover their
  state.db transcript (the #2782 self-heal 404 for deleted WebUI sessions is kept).
- static/sessions.js: add _isSubagentChildSession() + _sessionNeedsServerImportForLoad()
  (kept separate from _isExternalSession to avoid widening refresh-gating); the
  main session-tap, lineage-segment, and child-open handlers now trigger the
  import/merge path for subagent children.
- tests: test_5307_subagent_child_transcript.py (7 tests).

Fixes #5307
2026-07-01 07:15:19 +00:00
nesquena-hermes
d6c01245c5 stage #5024 2026-07-01 06:49:44 +00:00
nesquena-hermes
36436c9397 stage #5012 2026-07-01 06:45:22 +00:00
nesquena-hermes
9973b36cce stage #5268 2026-07-01 06:41:09 +00:00
b3nw
5d07c2de57 fix(model): extract custom bare-model repair helper; fix #1855 fast-path CI
- Add _repair_bare_custom_provider_model() shared by fast/slow paths (#5314)
- Use ordered model id list (config declaration order) for deterministic repair
- Shrinks fast-path block so test_issue1855 fast path stays before catalog call

Refs #5314

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 05:13:11 +00:00
b3nw
3bc875b718 fix(model): dynamically repair bare custom-provider models using active custom provider catalog
On subsequent turns (such as Turn 2), the client-side select dropdown can automatically normalize and strip the provider namespace prefix from the selection. On the next user input, the client POSTs the bare model ID (e.g. `grok-composer-2.5-fast`), which the server failed to repair back to its full qualified name unless it matched the suffix of the profile's configured default model.

This updates both fast-path and slow-path repair checks in `_resolve_compatible_session_model_state()` to dynamically query the active custom provider's configured models list in `config.yaml` (`custom_providers`). This guarantees that any bare model belonging to the configured custom provider is dynamically re-qualified back to its fully-namespaced form, regardless of whether it is the profile's configured default model.

Refs #5314

Co-authored-by: b3nw <b3nw@users.noreply.github.com>
2026-07-01 04:42:26 +00:00
Charles Inglis
b80ea016b1 fix: guard cursor-acp/copilot-acp before config lookup, fall through on all-invalid list
Addresses Greptile review feedback on PR #5313:
1. Move cursor-acp/copilot-acp guard before step 0 so a stray config
   entry can't surface unsupported effort options for those providers.
2. Only short-circuit when the filtered list is non-empty; an all-invalid
   list (e.g. typos) falls through to heuristics instead of hiding
   reasoning support from the UI.
2026-06-30 23:28:03 -05:00
Charles
26fb3745f9
Update api/config.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-30 23:25:43 -05:00
Charles Inglis
b84832e999 feat: support per-provider reasoning_efforts in config.yaml
Add a config-driven path to resolve_model_reasoning_efforts() that reads
providers.<name>.reasoning_efforts from config.yaml. This lets users
explicitly list valid reasoning effort levels per provider, so the
WebUI dropdown only shows options the model actually supports.

Handles both custom:<name> and bare registered provider names.
Falls through to existing heuristics (Copilot per-model lists, LM Studio
live API, models.dev) when no config entry is present.
2026-06-30 23:06:09 -05:00
Paladin173
d8f0f8f97f Fix composer control reorder rebase collision
Reapply footer control ordering on current upstream/master while preserving the required situational chip renderer. Persist composer_control_order with backend validation, keep the settings descriptions reorder-aware, and make primary/situational chip renderers participate in same-group drag ordering.

Verified with: node --check static/boot.js; node --check static/panels.js; git diff --check; ./scripts/test.sh tests/test_issue4598_composer_control_visibility.py
2026-06-30 21:11:54 -05:00
Rod Boev
2ea5fe06fa fix(#3825): harden oidc endpoint and claim validation 2026-06-30 20:59:19 -04:00
nesquena-hermes
d257e5f36c fix(security): gate embedded-terminal endpoints to local origins when auth disabled
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.
2026-07-01 00:40:25 +00:00
nesquena-hermes
9bd71e9bae Merge #4988 (enihcam prune orphan zero-msg sessions) into stage
# Conflicts:
#	CHANGELOG.md
2026-07-01 00:19:56 +00:00
nesquena-hermes
3e84d7bf11 Merge #4738 (savagebread neon skins + skin-persistence fix) into stage 2026-06-30 22:21:28 +00:00
nesquena-hermes
0b2507ac9f fix(#4738): register neon-soft/neon-paint in _SETTINGS_SKIN_VALUES (server-side skin persistence) 2026-06-30 22:13:36 +00:00
nesquena-hermes
78ca4d43a1 Merge #5005 (futureworld678 Shift+Enter) into stage 2026-06-30 21:51:42 +00:00
nesquena-hermes
0b74397fa6 Merge #5120 (rodboev budget limit) into stage 2026-06-30 21:28:51 +00:00
nesquena-hermes
8a5172275d Merge #5002 (per-project quick-create, opt-in) into stage 2026-06-30 20:56:05 +00:00
nesquena-hermes
ba401d3784 Merge #5079 (OpenAI TTS + SSRF hardening) into stage 2026-06-30 20:39:04 +00:00
nesquena-hermes
eb2c4b1e7d fix(#5079): no-redirect opener for OpenAI TTS (block redirect-to-private SSRF + bearer leak)
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.
2026-06-30 20:37:43 +00:00
nesquena-hermes
6bcb728f08 fix(#5079): block private/link-local/reserved IP targets in OpenAI TTS base_url (SSRF hardening)
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.
2026-06-30 20:37:43 +00:00
nesquena-hermes
b098e5c658 feat(#4676): gate per-project quick-create buttons behind opt-in setting
Add a default-OFF 'project_quick_create_buttons' setting that gates the
per-project '+' quick-create buttons on sidebar project chips. The chip
render site only calls _attachProjectQuickCreateButton when the new
window._projectQuickCreate runtime flag is enabled, so the buttons do
not appear unless the user opts in via Settings.

Mirrors the large_text_paste_as_attachment opt-in plumbing (defaults,
allowlist, settings collect/restore/save, window hydration at both sites,
checkbox row, i18n across all locales).
2026-06-30 19:49:07 +00:00
Lester
b1c11939aa Add Shift+Enter send key option 2026-06-30 18:04:29 +00:00
nesquena-hermes
121059b384 Merge #5161 (pr/5144-busy-composer-placeholder-hint) into stage-w5 2026-06-30 17:25:03 +00:00
allenliang2022
4ef9d7c5cb fix(webui): render a server-initiated turn that finished during an SSE gap on a visible tab
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.
2026-06-30 17:13:45 +00:00
Maude Bot
5263b9acf7 feat: add Webhooks sidebar filter parity
Co-authored-by: Kilian Tyler <kilian@kil.dev>
2026-06-30 17:06:31 +00:00
nesquena-hermes
3a12ac6bb8 Merge #5264 (fix/large-text-paste-toggle-upstream) into stage-w2 2026-06-30 16:35:55 +00:00
nesquena-hermes
b76264c310 Merge #5272 (fix/5141-terminal-failure-transcript-evaluator) into stage-w1 2026-06-30 16:19:47 +00:00
Rod Boev
5f07e12387 fix(#5270): route the continuity refresh through chat start 2026-06-30 08:33:20 -04:00
Rod Boev
cbe36c0ada fix(#5270): confine continuity repair to the chat path 2026-06-30 08:18:43 -04:00
Rod Boev
7e0936f3f1 fix(#5270): preserve first WebUI continuation context for CLI sessions 2026-06-30 07:47:57 -04:00
nankingjing
c2bb4c655d refactor(streaming): split terminal-failure transcript evaluator (#5141)
Extract _turn_transcript_lacks_final_assistant_answer() so settlement
logic can inspect an already-merged transcript. The merge wrapper now
delegates to the pure evaluator without changing behavior.

Fixes #5141
2026-06-30 18:40:08 +08:00
justanotherAIcontributor
91d32c40c5
Merge branch 'master' into fix/large-text-paste-toggle-upstream 2026-06-30 16:13:49 +10:00
Rod Boev
51a0fca24a fix(#5092): continue WebUI goals after gateway turns 2026-06-30 05:04:32 +00:00
nesquena-hermes
468b61e513 fix(auth): release STREAM_SESSION_OWNERS on worker early-return + stale-run prune
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>
2026-06-30 04:39:00 +00:00
Hermes Agent
fabd461dcd fix(auth): guard profile-owned stream IDs 2026-06-30 04:28:15 +00:00
Hermes Agent
a993fee060 docs(auth): document request session guard scope 2026-06-30 04:28:15 +00:00
Hermes Agent
f26896f96a refactor(auth): remove unreachable chat retag branch 2026-06-30 04:28:15 +00:00
Hermes Agent
9e861b5651 fix(auth): tolerate query-less route inputs 2026-06-30 04:28:15 +00:00