* fix: include terminal backend identity in agent cache signature (#5937) (#6175)
* fix(mobile): preserve extension panels in drawer sync
* fix: close mobile sidebar drawer when opening main-view create forms
The add-space/new-profile/new-skill/new-cron forms render in the main
view, which on phones is covered by the full-screen sidebar drawer
(z-index 200). Opening one from inside the drawer only focused the
input (keyboard popped up) while the form stayed hidden underneath.
- openWorkspaceCreate / openProfileCreate / openSkillCreate /
openCronCreate now close the drawer after rendering the form,
mirroring their *_Detail siblings.
- move the fromRailClick drawer re-open in switchPanel() into the
synchronous section: it used to run after await loadXxxPanel(),
racing (and re-opening over) a drawer close issued from inside the
drawer during the panel load window.
Verified in Playwright (390x844): drawer closes and stays closed after
plus-click, form visible with focus on the path input; desktop (1280x800)
unchanged; 83 mobile + 26 workspace tests pass.
* fix: close mobile sidebar after project quick-create plus
The per-project '+' button (_attachProjectQuickCreateButton) created the
new project conversation but never closed the mobile sidebar drawer, so on
phones the tap looked like a no-op: the session was created, just hidden
underneath the full-screen drawer (z-index 200).
Close the drawer in the success path after the sidebar repaint, mirroring
$('btnNewChat').onclick in boot.js and the #5409 close in
_openSidebarSession. Failure path keeps the drawer open so the error toast
stays visible for retry.
Adds static regression tests pinning the guarded close call and its
position after the repaint.
* test: pin quick-create drawer close to the success path (try block)
Greptile review note: the string-presence and ordering assertions alone
would still pass if the guarded closeMobileSidebar call were moved into
the later catch branch, silently reintroducing the mobile drawer bug.
Add an explicit assertion that the close sits before the success-path
catch(err), so a regression that moves it into the failure branch is
caught.
* docs(changelog): stamp batch1-p1 (#6921#6933#6671#6175)
---------
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Rodrigo Gomes da Silva <rodrigo.smscom@gmail.com>
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Addresses nesquena-hermes Round-2 review on #6979. All three MUST-FIX
items reproduced end-to-end are fixed; three SHOULD-FIX items folded in.
MUST-FIX 1 (data exfiltration) — capture/serve deny parity + source binding:
- The #3234 state/profile deny model is extracted into a single shared
predicate (routes._media_deny_reason) used by BOTH /api/media serving and
snapshot capture, so anything the serve path refuses is never captured in
the first place (STATE_DIR subdirs, webui_state subdirs, named-profile
roots, secret basenames, the snapshot store itself).
- Every captured digest now carries a server-owned canonical source-path
association (<digest>.src.json sidecar, tmp+rename atomic). /api/media
serves a digest only for the exact path it was captured from — replaying
a valid digest through a different allowed path falls back to the live
file instead of leaking stored bytes.
- Snapshot blobs are opened ANCHORED inside the store root (no-follow).
- Committed end-to-end regression: capture a denied state file and prove it
cannot be served through any path.
MUST-FIX 2 (data exfiltration) — configured store root denied regardless of name:
- get_snapshot_dir() is resolved and any bare path= target at or below it is
rejected BEFORE the allow/serve decision, so a custom
HERMES_WEBUI_MEDIA_SNAPSHOT_DIR (e.g. /tmp/custom-store-name) can no
longer be read via a plain path= request.
MUST-FIX 3 (silent corruption) — prefix-safe URL stamping:
- _stampMediaSnapshots now rewrites each COMPLETE path= query value
atomically (boundary-aware regex, decode whole value, exact map lookup)
instead of substring split/join. One path being a prefix of another
(/tmp/a.png vs /tmp/a.png.backup) can no longer corrupt the longer URL or
drop its digest. data-path placeholders use the same value-level rewrite
with HTML-entity unescaping. Prefix-path regression test committed.
SHOULD-FIX folded in:
- A recorded digest is FINAL: quota eviction no longer causes a later settle
to re-capture the current live bytes and rebind the historical message
(immutability restored + no quota-thrash I/O).
- is_valid_digest uses whole-string fullmatch (\Z) — a trailing newline can
no longer pass the gate.
- PDF/HTML fallback, download and open-full-page links now carry snap so
they open/download the frozen bytes.
Deferred (noted in review thread): emission-time capture (streaming.py) and
worklog scene-prose stamping — scene rows do not carry parent-message
snapshot metadata; threading it through the scene reconciler is a separate
data-pipeline change. Main transcript + transparent ordered segments remain
the old/new comparison surface.
Tests: +11 backend/frontend regressions (36 total in
test_media_message_snapshots.py), incl. the exact Round-2 repros.
Reverse-verified: the new tests fail on the round-1 head (8 failed).
- Revert all worklog/scene render-path changes: the scene render chain is
exercised by tests that extract function bodies and eval them in isolation
(_renderAnchorSceneRowsIntoWorklog, _anchorSceneNodeForRow, etc.); adding
helper references breaks those harnesses with ReferenceError.
- Snapshot stamping now covers the main transcript path and transparent
ordered segments only — the comparison surface for old/new previews.
- Transparent segment stamping is inlined in the msg-body template guarded by
typeof m, keeping the source-window string intact for test_issue3397 and
keeping the eval'd ordered-block self-contained for test_status_command_card.
- PDF/HTML lazy loaders keep the exact publicMediaUrl source string the tests
assert; snap is appended to mediaUrl only.
- Keep `const partBodyHtml=_getCachedRender(partDisplayText,false);` line
intact (test_issue3397 asserts the exact source string), apply snapshot
stamping on the following line via partBodyHtmlStamped.
- Drop unused roots_before variable and unused import in
test_media_message_snapshots.py (ruff F841/F401 on new lines).
- Update the source-invariant assertion to the two-line stamp shape.
PR #6922 made /api/media revalidate on every use, so overwriting a file in
place (same filename) also rewrote every historical chat preview that
referenced it — the old/new comparison was lost.
At settle time, snapshot each local-file MEDIA: reference into a
content-addressed store (<STATE_DIR>/media_snapshots/<sha256>.snap) and stamp
the message with _media_snapshots {path: digest}. The frontend appends
&snap=<digest> to historical preview URLs; /api/media serves the frozen bytes
with immutable caching when the digest is valid, and falls back to the live
file otherwise (eviction/never-captured). The existing path allow/deny checks
still gate the request — snap only selects which bytes to serve, it never
widens access.
- api/media_snapshots.py: content-addressed store (sha256 key, dedup, 50MB
per-file / 2GB total caps with FIFO eviction), MEDIA ref resolver, capture
allow-list mirroring the /api/media roots + #3234 deny list
- streaming.py: annotate settled assistant messages (idempotent, best-effort)
- routes.py: snap= serving branch + download_name override + deny direct
store-path access
- models.py: _media_snapshots registered in display-metadata merge keys
- ui.js: _stampMediaSnapshots post-cache stamping (main transcript,
transparent ordered segments, worklog scene prose) + data-snap threading
through the five lazy preview loaders
25 new tests incl. node-executed frontend behavior tests; reverse-verified.
The post-commit body-transmission block only caught
_CLIENT_DISCONNECT_ERRORS. A non-disconnect error during body write —
a generic OSError/EIO from f.read(), or a PermissionError — escaped
_serve_file_bytes entirely, and Handler.do_GET (server.py:388-395)
caught it and emitted a second status line after the already-committed
200/206, corrupting the HTTP stream.
Broaden the post-commit except to swallow every Exception (keeping the
disconnect branch for its debug message). Regression tests drive both
the snapshot and streaming paths with generic OSError/EIO and
PermissionError mid-body, asserting the status sequence is exactly
[200] with no trailing 500.
Codex SHOULD-FIX: add a committed test proving the de-dup retry variant never
clobbers a user-source manual rename (retry goes through the same provenance
rank guard). Additive test-only; no behavior change to the contributor's fix.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Codex round-2 SHOULD-FIX: add a committed test asserting the hinted bare-id
fallback returns null when two same-provider options normalize identically
(refuses to guess), plus single-match resolves and exact-match unaffected.
Additive test-only; no behavior change to the contributor's fix.
Co-authored-by: josephcy95 <josephcy95@users.noreply.github.com>
Codex round-1 SHOULD-FIX: the PR's only test change mocked the new write to a
no-op. Add real-SessionDB tests proving generated-title persistence, manual-rename
no-overwrite, and cli/gateway source preservation on ensure_session. Additive
test-only; no behavior change to the contributor's fix.
Co-authored-by: liuguangyong93 <liuguangyong93@users.noreply.github.com>
Move ETag/snapshot computation into a pre-commit try/except OSError so
read errors still convert to a clean 500 before any status line is sent.
Remove the outer OSError catch around body transmission: once
end_headers() returns the response is committed, and a client disconnect
(BrokenPipeError et al.) during the snapshot write or fdopen stream would
previously have triggered bad() -> send_response(500) as a second status
line on a dead stream. Body paths now catch _CLIENT_DISCONNECT_ERRORS
(same tuple as _safe_write()), log at debug, and return True.
Add regression tests with a fake writer that raises BrokenPipeError
after end_headers() for both body paths, asserting exactly one
send_response call.
When a file is truncated between fstat and the snapshot read, we now return
(None, data, actual_size) instead of (None, None, actual_size). This preserves
the captured bytes so the body path serves the immutable snapshot instead of
re-reading the fd after headers are committed.
This closes the second-order TOCTOU window: if the file shrinks AGAIN between
the first read and body transmission, Content-Length (from actual_size) and
body length (from snapshot) can never diverge.
Changes:
- api/routes.py:18494: return None, data, actual_size (preserve snapshot)
- tests: remove unused original_read (ruff F841)
- test_shrink_again_during_body_transmission_200: 200 advertised 6 / wrote 6
- test_shrink_again_during_body_transmission_range_206: 206 advertised 4 / wrote 4
Round 5 fix: when a file is truncated between fstat and the snapshot read,
Content-Length/Range headers must match the actual bytes read, not the stale
fstat size.
Changes:
1. Replace os.pread with os.lseek + looped os.read (cross-platform + short-read safe)
2. _etag_and_snapshot now returns 3 values: (etag, snapshot, actual_size)
3. Caller uses actual_size for Content-Length/Range calculations
4. If actual_size != file_size (truncation), etag=None → streaming fallback
Regressions:
- test_short_read_truncation_uses_actual_size: Content-Length == body length
- test_short_read_no_etag_on_truncation: no ETag on truncated snapshot
- Updated test_etag_no_body_on_pread_failure for os.read (was os.pread)
Native Windows opens files in text mode by default, which corrupts
binary media files via CRLF translation. O_BINARY prevents this.
This completes the portability story — the PR was already correct on
all supported platforms (Linux/macOS/WSL2), but the round-3 change was
explicitly about portability, so we close the Windows path too.
Address gate-review blockers from nesquena-hermes:
1. Wrong title-authority API: replace `db.set_session_title()` with
`db.set_auto_title_if_empty()` so auto-generated titles never
overwrite a manual rename (set_session_title writes with 'user'
authority; set_auto_title_if_empty only populates NULL titles).
2. Null profile fallback: pass `or 'default'` at both streaming.py
call sites so background title workers (which lack request TLS)
always resolve to the correct state.db instead of falling through
to the process-global active profile.
Neutralize the new `sync_session_title` call in
`test_cleared_title_allows_initial_auto_generation` to prevent state.db
side effects from leaking into shared test state, matching the existing
pattern of mocking collaborators in this test.
Background title generation (`_run_background_title_update` and
`_run_background_title_refresh`) writes the title to the WebUI sidecar
JSON but never syncs it back to hermes-agent's `state.db`. This causes
`hermes sessions list` to show blank titles for all WebUI-originated
sessions.
The existing `sync_session_usage` path is gated by the `sync_to_insights`
setting (default: off) and executes before the background title thread
completes, so it cannot cover this case.
Fix: add `sync_session_title()` to `state_sync.py` — an unconditional
(not gated by `sync_to_insights`) title-only sync — and call it from both
title generation paths immediately after persisting the title to the
sidecar.
When switching sessions in loadSession(), the window._compressionUi state
was not cleared, causing phantom 'Compressing context' barriers to appear
on fresh sessions that never triggered compression.
This complements PR #6572 which handles the case where compressed SSE
events are lost/delayed, but does not cover the session switch scenario.
Root cause: loadSession() tears down approval/clarify/composer state but
missed the compression UI state, which is per-session and must not leak.
Fix: Clear compression state immediately after hideClarifyCard() in the
loadSession() teardown sequence.