* fix(goal): suppress reserved SILENT sentinel at /api/goal ingress
A wake relay POSTing the exact [SILENT] suppression sentinel to
/api/goal lets it reach _start_chat_stream_for_session, which persists
it as pending_user_message. If 8701 restarts while the turn is pending,
session recovery materializes it as a visible _recovered user turn —
the same phantom-recovered-turn exposure #7018 closed for chat ingress.
Apply the shared _is_silent_control_message() guard immediately after
/api/goal session-ID validation and before session lookup or goal-state
mutation, returning the same 200 no-op. Matching stays exact and
case-sensitive; ordinary kickoff text is unaffected.
Add tests mirroring test_silent_control_suppression.py for the goal
path (args and text fields, before-lookup suppression, exact-match
semantics).
Closes#7019
* fix(sessions): keep evicted subagent parents in the import window
The sidebar nests a subagent row under its parent only when the parent row
is present in the same payload. The visible-window limit was applied as a
flat per-row recency slice, so a frozen orchestrator (which stops writing
while its leaves keep streaming) lost the recency race against its own
leaves and fell outside the window -- promoting those leaves to top-level
sidebar rows.
Re-add subagent parents that the oversampled candidate set already
projected, after the slice. No extra queries, no change to
CLI_VISIBLE_SESSION_LIMIT. webui ancestors are deliberately not imported
because that sidebar bucket already owns them.
Supersedes #7031.
* fix(sessions): document and pin the parent-recovery bound (greptile review)
Greptile flagged (P1) that a selected subagent child whose parent ranks below
the limit * 8 oversample is still promoted to a top-level row. That is real and
measured (the parent drops out at candidate #25 of 24), but it is the bound of
the design, not a regression: the walk reuses rows the projection already
fetched and never issues an extra query. Resolving arbitrarily old ancestors
needs an unbounded per-row lookup on the hot sidebar path -- the approach
rejected in #7031 -- so the bound is documented and pinned instead.
- Docstring: state that `limit` bounds the recency slice, not the row count,
so callers must iterate rather than assume len(rows) <= limit; state that
recovery is bounded by the oversampled candidate set.
- Inline comment: mark the bound at the exact lines Greptile flagged and point
at `candidate_limit` as the knob if the window proves too tight.
- Tests: cover the candidate-window exhaustion Greptile said was untested --
parent inside the oversample is recovered, parent beyond it stays unresolved
-- plus the over-limit return contract and a parent-cycle guard.
No behaviour change; 7 tests pass.
* fix(share): add _hadAppearance guard to prevent fabricated appearance choice
Summary:
The inline appearance bootstrap in static/share.html writes hermes-theme and
hermes-skin to localStorage unconditionally on every page load, even when the
browser had no prior appearance state. This fabricates an explicit user choice
on first access via a shared link, making the server-side SETTINGS_DEFAULTS
unreachable for deployments that customise the default theme or skin.
Root Cause:
share.html:9 — the boot IIFE resolves a theme+skin and calls
localStorage.setItem() without guarding on whether the user had previously
chosen an appearance. The same bug was fixed in index.html by PR #6808
(commit tomtong2015) but share.html was left unchanged.
Change:
1. Added _hadAppearance guard before the two localStorage.setItem() calls:
var _hadAppearance = localStorage.getItem('hermes-theme') !== null ||
localStorage.getItem('hermes-skin') !== null;
if (_hadAppearance) { setItem('hermes-theme', t); setItem('hermes-skin', s); }
2. The first-paint DOM mutations (classList.add('dark'), dataset.skin) remain
outside the guard — only persistence is protected.
3. Synced the skin allowlist with index.html: added neon-soft and neon-paint
(zeus and verdigris were already present).
Verification:
- test_6808_appearance_bootstrap_no_fabricated_choice.py: 11/11 passed
covering fresh-browser (no writes), pre-paint fallback, explicit state
normalisation, and legacy migration survival.
Closes#7030
* fix(tests): pin rebuild budget in issue2513 custom-provider catalog test
test (3.13, 4) failed on this PR with:
assert "@custom:alpha-proxy:alpha/remote" in alpha_ids
E AssertionError: assert '@custom:alpha-proxy:alpha/remote' in {'alpha/sticky'}
WARNING api.config:config.py:8355 live provider-catalog rebuild exceeded
4.0s budget - serving fallback, refreshing catalog out-of-band
Pre-existing wall-clock flake, not a regression from this PR: this branch
touches only api/agent_sessions.py and tests/test_subagent_parent_in_import_
window.py, and both api/config.py and this test file are byte-identical to
origin/master. The same shard passed on 3.11 and 3.12.
The test never pinned _LIVE_REBUILD_BUDGET_SECONDS, so it raced the global
4s budget in get_available_models(). On a starved runner the cold rebuild
overruns, the degraded fallback catalog is served, and the monkeypatched-
urlopen model alpha/remote is dropped - leaving only the config-declared
sticky model, exactly as CI observed.
Force the synchronous (unbounded) rebuild path, matching the existing
precedent in tests/test_issue2540_models_endpoint_error.py:20-24.
Verified: with the budget forced to 0.001s the unpatched test reproduces
the CI assertion verbatim; patched it passes at 0.001s, 0, and default.
* fix(#7013): preserve media deny coverage across platforms
* fix(docker): keep repository agents out of runtime context
* fix(docker): gate image runtime proof behind integration job
* test(models): stabilize custom provider catalog regression
* Release batch A: 6 low-risk gate-passed fixes (experimental)
Batched contributor fixes, each individually Codex-gated during the overnight
certifier cycles and re-verified clean-to-ship as a combined stage (Codex SAFE
TO SHIP on the combined diff; full suite green except 8 pre-existing approval
tests that fail identically on clean origin/master — CI green on same commit).
- #7019 (@webtecnica) suppress reserved [SILENT] sentinel at /api/goal
- #7031 (@carlotestor) keep evicted subagent parents in the sidebar import window
- #7030 (@webtecnica) share.html first-visit appearance guard + skin-id sync
- #6853 (@rodboev) exclude repo-root AGENTS.md from the Docker runtime image
- #7013 (@webtecnica) test-only: platform-neutral test portability (playwright
import guard + media tests served from allowed roots)
- test-only (@carlotestor) pin custom-provider catalog rebuild budget (#7054)
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: carlotestor <carlotestor@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: carlotestor <carlotestor@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: n <a@n>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(docker): register /usr/local/lib in ld.so.conf.d so the source-built SQLite loads on arm64
The #6900 SQLite-from-source upgrade passed the amd64 docker-smoke but FAILED the
multi-arch release build on linux/arm64 with 'AssertionError: SQLite 3.46.1 still
vulnerable' — Python's sqlite3 kept loading the base image's /usr/lib multiarch
libsqlite3 (3.46.1) instead of the freshly-compiled /usr/local/lib copy (3.53.0),
because /usr/local/lib is NOT in the default ld.so search path on Debian arm64
(it happened to be picked up on amd64). Register /usr/local/lib via an
ld.so.conf.d entry before ldconfig so the new lib wins on every architecture.
The PR's own build-time version assertion is what surfaced this (correctly), and
it also serves as the verification that the fix works once the arm64 build passes.
Follow-up to #6900 (exp-v0.52.227, whose multi-arch image failed to build/push).
* test-fix: keep wal.html link within 500 chars of ARG (condense arm64 comment)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix: compile SQLite 3.53.0 from source to fix WAL-reset corruption bug
The python:3.12-slim base ships SQLite 3.46.1 (Debian Trixie), which is
vulnerable to the WAL-reset corruption bug discovered March 2026.
Debian has not backported the fix.
Compiles SQLite 3.53.0 from the official amalgamation tarball during the
Docker build. Installs to /usr/local/lib (takes ldconfig priority over
/usr/lib). Build tools (gcc, make, libc6-dev) are purged in the same
layer. A build-time Python assertion fails the image build if the linked
library is still vulnerable.
Version and year are build args for easy bumps.
https://sqlite.org/wal.html#walresetbug
* fix: enable FTS5/FTS4/R-Tree and pin tarball SHA-256
Address review feedback (nesquena-hermes):
1. Add --enable-fts5 --enable-fts4 --enable-rtree to ./configure so the
compiled SQLite matches the distro package's feature set. Without
FTS5, state.db session/message full-text search breaks silently.
2. Pin the amalgamation tarball SHA-256 as a build arg and verify with
sha256sum -c before extraction.
3. Extend the build-time assertion to create and drop an FTS5 virtual
table, proving the module is available - not just the version number.
4. Add regression tests for checksum verification, FTS5 configure flag,
and FTS5 build-time vtable assertion.
* docker(sqlite): compile with SQLITE_SECURE_DELETE to preserve deleted-row erasure
Codex gate found a SILENT data-privacy regression: the Debian base image's SQLite is
built with SQLITE_SECURE_DELETE (PRAGMA secure_delete=1, deleted content overwritten),
but compiling 3.53.0 from the amalgamation without the flag drops it to 0 -> deleting a
session removes its rows but leaves transcript bytes recoverable in state.db. Reproduced
end-to-end via api/models.py delete path. Add CPPFLAGS=-DSQLITE_SECURE_DELETE and a
build-time assertion that PRAGMA secure_delete==1 (fails the image build if not).
Co-authored-by: qxxaa <qxxaa@users.noreply.github.com>
---------
Co-authored-by: qxxaa <qxxaa@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(ui): prevent OOM on tab focus for long sessions
Coordinate visibility-recovery and session-updated refresh paths so they
never start concurrent full-transcript loads, window the O(N) pre-render
scans to the virtual render window, and bound the render-window expansion.
Closes#6999
* fix(ui): full turn context + collision-free cache hash (#7006 review rework)
Addresses the maintainer's CHANGES_REQUESTED on PR #7006:
- Turn-context maps (_assistantTurnFinalVisibleContentMap /
_assistantTurnVisibleContentMap) receive the FULL visWithIdx again.
The virtualized render window (head+tail around a gap) cut through
assistant runs and merged distinct turns across an omitted user
boundary, which could duplicate the final answer in Worklog/Thinking
or feed later-turn prose into the reasoning echo-strip. Turn context
is input, not output: it must always be complete.
- _addBoundedHash now streams the FULL string form through the FNV-1a
loop (no length+head+tail clip, no slice copies), and object fields
(attachments, tc.args, compression-anchor keys) are walked key-by-key
so no integral JSON.stringify() is allocated. Same-length middle-only
edits of tool args, attachment metadata, tool snippets, or
compression-anchor keys no longer collide in _sessionHtmlCache.
Regression tests:
- tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn
with a virtualized gap proves full-context echo-strip suppresses the
exact final-answer echo, and documents the gapped head+tail failure.
- tests/test_issue2613_render_cache_signature.py: Node harness executing
the real _messageRenderCacheSignature() proves same-length middle-only
mutations change the signature for every structured field while the
old clip scheme collides on the same data.
Closes#6999
* fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash
Re-gate round 2 for #6999:
- session-updated frames arriving while the external-refresh guard is held
are latched per SID (max announced count) and drained by the refresh
owner's finally with ONE guarded follow-up, instead of being dropped
(production does not guarantee a second event).
- _hashObjectInto walks keys in insertion order (matching Object.entries
render projection) with an explicit per-key index discriminator, so
opposite-insertion-order argument objects no longer share a cache
signature; recursion depth is now threaded (children increment, not
restart).
* test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness
#7006 adds a reference to the pre-existing module var _loadingSessionId and a new
_drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated.
The jump-to-answer scroll-settle tests extract that function into a node harness, which
didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs
to the harness preambles (no-op drain, since these tests exercise scroll ownership, not
the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py).
Test-harness-only; production static/ untouched.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Keep reconnect confirmations transient so they stop consuming mobile composer space, while a single owned timer prevents stale notices from clearing newer status text.
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(appearance): don't let the boot script fabricate an explicit theme choice
`syncSettings` in static/boot.js is careful to let the server win on a first
visit. Its comment says why:
// empty (new-browser) state is indistinguishable from a user who chose
// the defaults. To avoid blocking server→client sync on first visit we
// only let localStorage override the server when it carries an explicit
// user-selectable theme value or a NON-DEFAULT skin.
The inline bootstrap in static/index.html defeats that. It resolves a theme with
`localStorage.getItem('hermes-theme')||'dark'` and then unconditionally writes the
result back:
localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);
On a brand-new browser that stores `hermes-theme=dark` before any request is made.
By the time syncSettings runs, `lsHasExplicitTheme` is true, so:
1. the server's `theme`/`skin` in SETTINGS_DEFAULTS (or settings.json) is ignored, and
2. the reconcile branch below POSTs the fabricated value back to /api/settings,
overwriting the stored setting it was supposed to be reading.
The net effect is that the server-side appearance default is unreachable: a fresh
browser always lands on dark and then persists it server-side. Deployments that
set a different default in SETTINGS_DEFAULTS never see it applied.
Fix: only rewrite localStorage when there was already appearance state to
normalise. A fresh visit now leaves localStorage untouched, so `lsHasExplicitTheme`
is false and syncSettings takes the server value exactly as its comment intends.
Deliberately keeps the existing normalisation. The write is not pointless — it
canonicalises legacy names (`solarized` → `dark` + `poseidon`). Gating on "was
there any prior state" rather than per-key preserves that: with `hermes-theme=solarized`
and no `hermes-skin`, the derived skin is still written, so the mapping survives
the next load. A per-key guard would have silently dropped it.
Verified by driving the bootstrap in node against a stubbed localStorage. Behaviour
is byte-identical to before for every input that has prior state — legacy
`solarized`, an explicit `light`+`mono`, and a skin-only `slate` all produce the
same painted result and the same localStorage. The only case that changes is the
empty one, which is the bug:
before {} → localStorage {hermes-theme: dark, hermes-skin: default} server locked out
after {} → localStorage {} server wins
Painting is unchanged throughout: a fresh visit still renders dark, since that is
still the fallback.
* test(appearance): pin absent vs explicit appearance state in the bootstrap
Adds the regression coverage asked for in review. Nothing previously
distinguished "no appearance stored" from "user chose these values":
tests/test_1003_appearance_autosave.py covers picker autosave and
tests/test_bugfix_sweep.py covers guarded storage access, but neither fails if
the bootstrap starts fabricating a choice again.
Executes the REAL bootstrap, lifted out of static/index.html, in node against a
stubbed localStorage and documentElement — the same harness used to verify the
fix, now committed instead of run by hand. Covers all three states the review
called out, plus the pre-paint assertion:
- both keys absent -> no setItem at all, AND the dark class is still applied
(the fix changes persistence only, never the paint)
- either key present -> both values normalised and persisted
- legacy names -> solarized/slate/monokai/nord/oled still migrate, and
the DERIVED skin is still written
That last group is why the guard is on the pair rather than per key: with
`hermes-theme=solarized` and no `hermes-skin`, the write of the derived skin is
the only thing that persists the mapping, so a per-key guard would silently drop
it on the next load.
Verified as a regression test, not a tautology: against the pre-fix
static/index.html, test_fresh_browser_writes_nothing and
test_guard_is_on_the_pair_not_per_key both FAIL, while the other nine still pass
— the unfixed bootstrap does still paint dark and does still persist
migrations, so only the buggy behaviour differs.
One harness note worth keeping: the driver and the extracted script are passed
as FILES (`node <driver> <script>`). Passing the script via `node -e ... --
<script>` shifts process.argv, so the driver eval'd the JSON scenario instead of
the bootstrap — and since `eval('{}')` is a valid empty block rather than a
syntax error, the empty-store case passed while executing nothing. _run() now
asserts the bootstrap produced some effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(appearance): keep `var themes=` intact so the skin tests still find the block
CI caught this; review could not. `tests/test_sienna_skin.py` (x2) and
`tests/test_catppuccin_skin.py` locate the inline bootstrap with
init_script_idx = INDEX_HTML.find("var themes=")
end_idx = INDEX_HTML.find("</script>", init_script_idx)
init_block = INDEX_HTML[init_script_idx:end_idx]
Declaring `_hadAppearance` as the first `var` in that chain turned `var themes=`
into `,themes=`, so `find()` returned -1, `init_block` came out EMPTY, and three
assertions failed against the empty string — including "Default theme must remain
'dark'", which read as though this PR had changed the default. It had not: the
default is untouched, the block simply could not be located any more.
Splitting the declaration (`;var themes=` instead of `,themes=`) restores the
literal. Both are `var` in the same function scope, so behaviour is identical —
the regression module still passes unchanged, including the empty-store case
(no writes, dark class still applied) and all five legacy migrations.
Failed on shards 3 and 4 across 3.11/3.12/3.13 while the same shards passed on
master, which is what pinned it to this change rather than flake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Hermes Agent <hermes@aip.de>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
Cron agents use the exact final response `[SILENT]` as a delivery-suppression
sentinel. If a wake relay accidentally POSTs that sentinel to `/api/chat/start`
and 8701 restarts while the turn is pending, session repair materializes it as
a visible `{role: user, _recovered: true}` message. The pending value can then
be recovered again on later restarts, creating repeated `[SILENT]` turns.
Treat the exact normalized sentinel as a successful no-op at both server-side
turn entry points: the HTTP `/api/chat/start` handler and
`start_session_turn`. Both checks run before session lookup, runtime barriers,
or pending-state mutation. Matching is deliberately exact and case-sensitive,
so `[silent]`, prose containing `[SILENT]`, and ordinary user text are not
suppressed.
Add regression tests proving both paths return HTTP/status 200 without session
lookup, plus negative cases for non-exact text. The new tests fail 3/3 before
the fix. Targeted and neighbouring chat-start suites pass 21/21.
Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix: prevent kanban rows from hiding CLI sessions
Keep worker-heavy Kanban history out of the bounded interactive session window and recover it through a separate capped pass. Add regression coverage and update query-pass contract tests.
* docs: describe imported session projection bounds
---------
Co-authored-by: zicochaos <23367003+zicochaos@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
When the user switches from a streaming session (A) to another session (B),
A's streaming text could render in B's window because:
1. The token handler guards against rendering when the session is not active,
but the rAF/setTimeout window between _scheduleRender() and _doRender()
can outlive a session switch.
2. _doRender() writes to assistantBody without checking whether the stream's
session is still the active frontend pane.
Fix: add _isActiveSession() guards to:
- _scheduleRender() — drop scheduled renders when the session changed
- _doRender() — bail before any DOM write if the session switched
- _flushPendingSegmentRender() — defense-in-depth for any future callers
These guard at the same level the token/interim_assistant event handlers
already do, closing the rAF gap that could leak streamed text into a
different session's view.
Co-authored-by: webtecnica <contato@webtecnica.com.br>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(chat): keep response jump position after session load
Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.
* fix: gate response jump ownership by destination
* fix: keep response jumps programmatic through smooth scroll
Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.
- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener
* fix(scroll): hold reader off-bottom during jump owner + cancel jump on explicit End (#6621 gate fixes)
Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
owner suppressed the scroll listener but left the pre-jump pinned state, so
scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
the stale unpinned snapshot. scrollToBottom() now cancels the active jump
owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
non-reconcile cancel doesn't leak the transient unpinned state.
* fix(scroll): drop undefined currentSid global ref in _messageJumpSessionId (#6621 brick-class scope-undef gate)
The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.
* fix(scroll): round-2 gate fixes for jump-owner state transitions (#6621)
Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
expires now explicitly establishes the unpinned reader-owned state (was
cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
pinned-state assignments, so a stream starting mid-jump can't have its pin
undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
reconciliation when the terminal state is pinned to the tail, so a refresh
deferred during the temp-unpin window isn't stranded.
* fix(scroll): make jump-owner guards typeof-safe + widen static test window (#6621)
The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.
* test(scroll): add streaming-frame-hold + wheel-during-jump regression tests (#6621 Fable finding ii)
Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
hands ownership to the reader UNPINNED at their position, never pinned
mid-transcript.
* fix(scroll): widen jump-owner takeover to downward + touch input (#6621 Fable S3)
Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the #4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.
---------
Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(providers): reduce pool-exhaustion TTL for HTTP 402 from 1h to 2min
Mirrors the agent-side fix: when a provider returns HTTP 402 (Payment
Required), the credential pool marks the key as exhausted with a 1-hour
TTL — even though recharging credits restores the key immediately.
Both copies of _entry_exhausted_ttl_seconds() now handle HTTP 402 with
a 2-minute TTL, matching the new EXHAUSTED_TTL_402_SECONDS constant in
agent/credential_pool.py.
* fix(providers): tie HTTP 402 TTL to installed runtime contract (#6626)
* test(providers): behavior-test 402 TTL 119/120s boundary on both helper copies (#6626)
Add executed boundary tests for the HTTP 402 pool-exhaustion TTL:
- Host helper: with the runtime TTL monkeypatched to 120, an exhausted
entry is still unavailable at 119s and becomes available at 120s, for
both the integer 402 and the persisted string "402" error codes.
- Embedded _ACCOUNT_USAGE_SUBPROCESS_CODE copy: execute the subprocess
code under the existing quota-subprocess harness and assert the same
TTL and 119/120s eligibility boundary against the embedded helper,
instead of counting source-string occurrences.
- Keep the legacy 3600s fallback and last_error_reset_at precedence
assertions.
* test(providers): make 402 TTL parity tests self-contained for standalone CI
The parity tests imported agent.credential_pool directly, but the WebUI
test suite runs standalone (Hermes Agent runtime is not installed in CI),
causing ModuleNotFoundError on every shard since 08-02. Simulate the
runtime contract with a fake agent.credential_pool module (same pattern
already used by the embedded-subprocess helper test) so both the 120s
cooldown and the legacy 1h fallback paths are covered.
---------
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* feat(docker): enable gateway API and password forwarding in compose files
- Added API_SERVER_KEY forwarding to enable the gateway API listener in multi-container setups.
- Configured HERMES_WEBUI_PASSWORD to ensure password authentication works as expected.
- Updated documentation to clarify API_SERVER_KEY requirements and forwarding behavior.
- Added regression tests to cover the new functionality and ensure proper configuration.
* build(docker): force LF line endings for shell scripts via .gitattributes
A CRLF shebang (#!/bin/bash\r) breaks locally-built Docker images: the
kernel can't resolve the interpreter /bin/bash\r, so 'docker compose up
-d --build' on Windows with core.autocrlf=true fails with
'exec /hermeswebui_init.bash: no such file or directory'. The repo blobs
and CI/GHCR builds are LF and unaffected; only Windows local builds
baked the CRLF checkout into the image.
* fixup(docker): dedup API_SERVER_HOST after rebase onto #6968 (which already added it)
* docs(changelog): stamp #6917 (multi-container gateway API forwarding)
---------
Co-authored-by: Yang <yang_qwq@qq.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(#6335): filter unconfigured providers from model selector
When a known provider (e.g. openai-api) is listed in config.yaml's
providers section with only metadata (name, display_name) and no route
configuration or credentials, it should not appear in the chat model
selector dropdown.
Previously, any provider recognised as a 'known provider' (present in
_PROVIDER_MODELS or _PROVIDER_DISPLAY) would be admitted to the picker
simply by being listed in the config.yaml providers block — even after
the user removed its API key. This caused a persistent ghost provider
entry in the dropdown (#6335).
The fix gates known-provider admission on credential evidence: the
provider must already have been detected from a credential source (env
vars, hermes auth, or credential pool) OR carry explicit route
configuration (api/base_url/api_key/key_env) OR be the active provider
with a configured models allowlist.
* fix(#6338): normalize provider aliases in credential check
The credential check compared _canonical (config key like 'x-ai',
'google') directly against detected_providers (alias-resolved values
like 'xai', 'gemini'). Alias-named authenticated providers were
therefore incorrectly treated as unconfigured and removed from the
model selector.
Fix: resolve _canonical through _resolve_provider_alias() before the
membership test, so config keys like 'x-ai' match credential evidence
reported as 'xai'. Also check the raw canonical for cases where the
config key is itself the alias target.
Refs: #6335, #6338
* fix(#6338): normalize detected_providers through alias resolver
Normalize both _canonical and detected_providers entries through
_resolve_provider_alias before the membership test, so that a WebUI
canonical form in detected_providers (e.g. x-ai added by a prior
loop iteration) also matches the alias-resolved form used in
credential detection (e.g. xai from hermes_cli).
This is belt-and-braces on the existing _resolve_provider_alias(_canonical)
check: both sides now map into the same alias-resolved namespace,
closing any edge case where detected_providers contains a WebUI-canonical
form that happens to differ from the alias target.
* test(ui): catalog-admission regression for credentialed providers (#6338)
* test(ui): pin config path state in catalog-admission regression (#6338)
* fix(system-health): expose runtime memory diagnostics without blocking owner locks (#6351)
* fix(system-health): keep owner contention local to runtime status (#6351)
* docs(changelog): stamp #6338#6363 (certified batch)
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(stream): preserve fade nodes while prose is live
* fix(stream): preserve transparent fade nodes like assistant prose
The transparent-activity prose path (transparent_stream mode) kept the
pre-PR fade cleanup: _bindTransparentFadeCleanup() in static/ui.js still
replaced every animated stream-fade-word span with a fresh text node on
animationend, invalidating the native scroll anchor while the row is
still growing.
Mirror the messages.js fix so both live prose paths share one
node-preserving cleanup: remove only the transient is-new class and the
--stream-fade-ms inline property, keeping span identity until final
settlement rebuilds the persisted DOM.
Extend tests/test_smooth_text_fade.py with a sibling regression that
extracts the production _bindTransparentFadeCleanup() from ui.js and
fires animationend, asserting no replacement, is-new removal,
stream-fade-word retention, and timing-override cleanup (plus
source-string guards against replaceWith in the cleanup block). The new
test is red-before-green against the previous ui.js implementation.
* docs(changelog): stamp #6257 (stream fade scroll-bounce fix)
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(renderer): exclude CJK full-width punctuation from autolink URLs
* fix(test): widen autolink block search window for CJK punctuation fix (#6792)
The CJK full-width punctuation fix expanded the autolink regex and
comment text, pushing target='_blank' and esc(clean) beyond the 600/
700-char search windows in tests. Widen to 1200 chars.
* fix(test): widen autolink window in test_issue470 to match 1200-char block
The CJK punctuation fix added comment lines that pushed href="${clean}"
beyond the 600-char search window in test_issue470.py::test_autolink_no_esc_on_href,
causing the same failure across 3.11/3.12/3.13 shard-4. test_issue342.py was
already widened in cc52202f; apply the same 1200-char window here.
* fix(test): drive real renderMd() for CJK full-width autolink behaviour
Replace the test-owned FIXED_REGEX copy in test_fullwidth_autolink.js with
a pytest + node harness that extracts and runs the ACTUAL renderMd() from
static/ui.js (same driver pattern as test_renderer_js_behaviour.py).
Coverage per review:
- every CJK trailing mark (),。;:!?、) through both the outer
paragraph pass and the inline pass (list items + blockquotes)
- ASCII trailing marks (regression guard)
- normal URLs with query params (href keeps raw &, text is escaped)
- Markdown links and pre-existing anchors are stashed, never re-wrapped
Reverse-verified: reverting the \uFF09 exclusion + CJK strip makes 27 of
38 assertions fail on the real production code.
* fix(test): drop stray f-prefix from placeholder-free asserts (F541)
* docs(changelog): stamp #6792 (CJK autolink punctuation)
---------
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* 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>