Commit graph

2588 commits

Author SHA1 Message Date
pxxD1998
cc6c87903a fix(session): bound delete lock wait 2026-07-29 18:22:22 +00:00
pxxD1998
e2b052a637 fix(workspace): persist recovered session bindings 2026-07-29 18:22:22 +00:00
happy5318
a57d576805 fix: make prefixed Qwen detection positive-only to avoid shadowing hybrid IDs
Gate review found that the broadened token-prefix check caused
deepseek-r1-distill-qwen2.5-bakeneko-32b to enter the Qwen branch
(qwen2 token), return False, and prevent the DeepSeek detector from
running — a silent regression.

Fix: split into two phases:
1. Positive-only scan: any token matching qwen(\d+) with major >= 3
   immediately returns True. No False path — Qwen 2.x in hybrid IDs
   falls through to subsequent detectors.
2. Terminal guard (original behavior): standalone "qwen" token or
   normalized.startswith("qwen") still applies the 3+ gate with
   return False for 2.x, since those ARE pure Qwen models.

Add regression test for deepseek-r1-distill-qwen2.5-bakeneko-32b.
2026-07-29 18:07:55 +00:00
happy5318
90250adfb7 fix: address review — remove redundant guard, add regression tests
- Drop the now-subsumed `normalized.startswith("qwen")` guard
  (token-prefix matching already covers the bare case)
- Add test_qwen_prefixed_alias_reasoning_detection covering:
  prefixed Qwen 3+ (al-/sn-), bare Qwen 3+, and Qwen 2.x exclusion
2026-07-29 18:07:55 +00:00
happy5318
041e311f1e fix: Qwen reasoning heuristic fails for prefixed model IDs
The Qwen branch in _candidate_supports_reasoning() checks
"qwen" in token_set, but model IDs like "al-qwen3.8-max-preview"
(New-API alias prefixes) normalize to tokens ["al", "qwen3", "8", ...]
where "qwen" is not a standalone token. This causes the reasoning
effort toggle to disappear for any prefixed Qwen model.

Switch to token-prefix matching (token.startswith("qwen")), consistent
with the position-independent approach already used for DeepSeek
(tokens.index). This handles arbitrary vendor/alias prefixes while
still correctly excluding Qwen 2.x via the existing version gate.
2026-07-29 18:07:55 +00:00
StefanPieter
8db75f8b78 fix: keep SIGUSR1 stack dumps non-terminating 2026-07-29 17:41:46 +00:00
nesquena-hermes
b145f59312 fix(titles): reject trivial-echo new session titles (gate-rebase of #6529)
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-07-26 20:22:25 +00:00
nesquena-hermes
c49568101c fix(sse): close relays on apperror terminal events (gate-rebase of #6527)
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-07-26 19:45:38 +00:00
nesquena-hermes
44877274da Release exp-v0.52.151: repair admin model reload rebinding (#6477, @mspgle-dotcom) 2026-07-26 02:08:30 +00:00
ai-ag2026
a31cdb7765 fix(terminal): unsubscribe when the SSE header write fails after attaching
Adversarial review of the previous commit found a leak it introduced.

`attach_terminal()` subscribes and only then are the response headers written.
A client that dropped in that instant makes `end_headers()` raise
BrokenPipeError/ConnectionResetError, and because the `try/finally` started
*after* those writes, the exception escaped `_handle_terminal_output` with the
queue still in `_subscribers`. That pins `unwatched_since` at None — only
`unsubscribe()` on an empty subscriber list resets it — so the terminal became
permanently unreapable: the exact fd/thread leak (#4633) this reaper exists to
prevent, and strictly worse than the TOCTOU the previous commit fixed, since
that at least still allowed cleanup.

The try now starts immediately after the attach, so every exit path
unsubscribes. Regression test drives a handler whose `end_headers()` raises and
asserts the subscriber list is empty, `unwatched_since` is restored, and the
terminal is reapable again once the grace elapses. It fails against the
previous ordering.
2026-07-25 03:17:30 +02:00
ai-ag2026
07d110a064 fix(terminal): close the reaper's selection→reconnect→close TOCTOU
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.

- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
  holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
  entry in that same critical section: same object, no subscribers, and
  `unwatched_since` still set and still expired. A dead process is still
  reaped unconditionally — it cannot come back, and a viewer watching a corpse
  must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
  under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
  route. It takes the same two locks in the same order as the claim, so
  exactly one side wins: the viewer attaches (and the reap is refused), or the
  entry is already gone (and the route answers "terminal not running" before
  committing a 200, instead of streaming from a corpse). Registration is the
  authority — a registered-but-`closed` terminal still attaches so the viewer
  receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
  released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
  `put_output()`.

Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
2026-07-25 00:33:41 +02:00
ai-ag2026
932e858994 feat(terminal): reap idle abandoned terminals so #4633 can't accumulate
The fd-leak fix retires a terminal whose shell EXITS, and _MAX_TERMINALS bounds
the worst case, but an interactive shell abandoned by its client (tab closed
without POSTing /api/terminal/close, browser crash, network drop) keeps running
forever — there is deliberately no PDEATHSIG. A background reaper now closes any
terminal that has had zero attached output streams for longer than a 15-minute
idle grace, plus any dead-process terminal as a belt-and-suspenders for the
reader-loop retire.

Attachment is tracked on the subscriber model from the output-broadcast change:
subscribe() clears unwatched_since, the last unsubscribe() sets it. A terminal
is born unwatched, so a spawn nobody ever attaches to is reaped too; the grace
spans a tab refresh / brief drop so a real reconnect keeps the session. Eviction
uses close_terminal(sid, expected=term) so a restart replacement is never torn
down. The reaper thread is a lazily-started daemon singleton like the spawn
supervisor.

Stacked on the terminal fd-leak + output-broadcast changes (same file).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 00:30:06 +02:00
ai-ag2026
1603eb8611 fix(terminal): stop leaking fd + thread + shell on client/​shell exit
Two embedded-terminal resource leaks (#4633):

1. fd + dict entry on shell exit. When a shell exited on its own (user typed
   `exit`, the process died), _reader_loop broke out of its loop but only set
   `closed` and emitted terminal_closed — it never closed the pty master_fd nor
   removed the _TERMINALS entry. Only an explicit close_terminal / restart /
   atexit did that, so every self-exited shell leaked its master fd and dict
   entry for the rest of the WebUI's uptime. The reader loop now retires the
   session (close_terminal with an `expected=` identity guard so a restart's
   replacement is never torn down).

2. Unbounded accumulation of abandoned terminals. A client that drops its output
   stream without POSTing /api/terminal/close (tab close, crash, network drop)
   leaves the shell running (no PDEATHSIG, by design). _MAX_TERMINALS (32) now
   caps the live population, evicting a dead-proc terminal first, else the
   least-recently-active one, to make room.

Also hardens an fd-reuse TOCTOU that #1 would otherwise make reachable on the
common `exit` path: write_terminal / resize now re-check `closed` and touch
master_fd under a per-terminal io_lock that close_terminal also holds around
os.close, so input can never land on a closed-and-recycled fd number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 00:30:05 +02:00
Hermes Agent
b104abeae6 fix(admin): repair model reload rebinding 2026-07-24 18:05:17 +08:00
b3nw
e93b0c352a
feat: render process wakeups as collapsed summary cards (#6350)
Render structured background-process completions and watch matches as accessible collapsed cards while preserving raw fallback, verbatim output, and full watch patterns.

Co-authored-by: b3nw <b3nw@duck.com>
2026-07-23 20:52:51 +00:00
nesquena-hermes
0d06ae3467 Merge remote-tracking branch 'origin/master' into stage/6040 2026-07-23 02:26:42 +00:00
webtecnica
02b5f885c8 fix(share): harden _embed_share_media with path allow-list + mime validation (#6174)
Embed local MEDIA: image refs as inline base64 at snapshot-build time,
gated by a workspace/attachment-root allow-list, 512KiB cap, image-MIME
allow-list, and magic-byte content validation. Everything else (traversal,
symlink-escape, file://, non-image, SVG, oversize) becomes a static
placeholder. Closes P0 #6174 (public shares no longer emit authenticated
/api/media requests for local paths).

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-07-23 01:21:59 +00:00
nesquena-hermes
bbb417b3f1 stage: merge #6113 (per-turn used-model chip in transparent footer) 2026-07-22 22:13:08 +00:00
Rod Boev
8119d91dc3 fix(#6008): serialize identity-less approval relays 2026-07-22 15:06:33 -04:00
nesquena-hermes
f39cedafca stage: merge #5797 2026-07-22 18:45:24 +00:00
nesquena-hermes
65a3f795eb stage: merge #6372 2026-07-22 18:45:24 +00:00
Rod Boev
5a954c79d3 fix(#6008): keep gateway cancel authority stable across startup teardown 2026-07-22 12:33:26 -04:00
Rod Boev
2c6ce96596 fix(#6008): wait for gateway stop acknowledgement 2026-07-22 12:33:26 -04:00
Rod Boev
4a71e407b1 fix(gateway): keep retained no-run approvals visible 2026-07-22 12:31:12 -04:00
Rod Boev
0a3a630856 fix(gateway): keep approval and stop acknowledgements honest 2026-07-22 12:31:12 -04:00
Rod Boev
918b9ddb63 test(gateway): align approval lifecycle regressions 2026-07-22 12:31:12 -04:00
Rod Boev
fb88095530 fix(gateway): require agent-issued approval identities 2026-07-22 12:31:12 -04:00
Rod Boev
a91bd88c70 fix(gateway): preserve approval identity provenance 2026-07-22 12:31:12 -04:00
Rod Boev
f30178e18a fix(gateway): fall back safely without approval identities 2026-07-22 12:31:12 -04:00
Rod Boev
b481dfea13 fix(#6008): keep retained approvals visible in session attention 2026-07-22 12:31:12 -04:00
Rod Boev
5826d76da0 fix(#6008): publish the authoritative approval head 2026-07-22 12:31:12 -04:00
Rod Boev
6460c48ce6 fix(#6008): keep gateway approvals attached to the right run 2026-07-22 12:31:12 -04:00
Rod Boev
b907630f8f fix(#6008): keep gateway approvals attached to the right run 2026-07-22 12:31:11 -04:00
Rod Boev
489728d71f fix(gateway): scope approval mirror cleanup to live heads (#6008) 2026-07-22 12:19:33 -04:00
Rod Boev
39fc169834 fix(#6008): route gateway approvals by run ID 2026-07-22 12:19:33 -04:00
nesquena-hermes
e9e9ed09a5
Release: #6148 alias-prefix model routing fix (exp-v0.52.141) (#6418)
* fix: resolve provider alias before matching _PROVIDER_MODELS (#6148)

* fix(backend): resolve model prefix through _PROVIDER_ALIASES before matching

- Fix IndentationError: dedent _canon_prefix assignment to 8 spaces
- Rebased onto upstream/master
- Add regression test: alias prefix (z-ai) resolves to canonical key (zai)
  and routes cross-provider through OpenRouter (#6131)

* fix(#6131): canonicalize both sides via _canonicalise_provider_id (idempotent) + regression tests

Codex found _resolve_provider_alias breaks Qwen (qwen->alibaba, not in _PROVIDER_MODELS)
and cross-routes alias-of-active-provider (raw vs canon asymmetry). Use the idempotent
_canonicalise_provider_id for both membership and equality. Adds 2 regression tests.

* docs(changelog): stamp #6148 alias-prefix model routing fix

---------

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <agent@webui>
2026-07-22 09:51:37 +00:00
nesquena-hermes
478f7749ab
Release: #6107 per-model reasoning_efforts precedence (exp-v0.52.140) (#6417)
* fix: honor per-model reasoning effort metadata

* docs(changelog): stamp #6107 per-model reasoning_efforts precedence

---------

Co-authored-by: Charles Inglis <6466275+CharlesMcquade@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <agent@webui>
2026-07-22 07:50:29 +00:00
nesquena-hermes
8586572da5
Release: #6389 stream-teardown leak fix (exp-v0.52.139) (#6416)
* fix(#6389): remove clear_offline_buffer() teardown blocks that cause data loss on late reconnects

Keep the real leak fixes:
- unregister_stream_owner() in both streaming.py and gateway_chat.py
- AGENT_INSTANCES.pop() in gateway_chat.py

The clear_offline_buffer() calls could clear a channel's offline buffer
after a late reconnect already fetched the channel but before it subscribed,
causing the reconnecting client to lose all buffered events (including
stream_end) and hang indefinitely on heartbeats.

STREAMS.pop() already releases registry ownership; GC reclaims the buffer
once the last outstanding channel reference is dropped, correctly preserving
replay for in-flight reconnects.

* docs(changelog): stamp #6389 stream-teardown leak fix

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <agent@webui>
2026-07-22 04:09:49 +00:00
webtecnica
700bd976c1 fix(media): narrow MIME_MAP to only .ts/.tsx text/plain, add regression tests
- Add only .ts→text/plain and .tsx→text/plain to MIME_MAP (exclude .js,
  .jsx, .css and other unsafe mappings that would make workspace files
  executable as same-origin scripts)
- Add test_ts_artifact_served_as_text_plain_with_attachment — asserts
  Content-Type: text/plain, Content-Disposition: attachment, and
  X-Content-Type-Options: nosniff for .ts files via /api/media
- Add test_tsx_artifact_served_as_text_plain_with_attachment — same for .tsx
- Add test_file_raw_js_not_served_as_inline_executable — proves that a
  workspace .js file falls through to application/octet-stream instead of
  text/javascript via /api/file/raw

Closes #6372
2026-07-21 20:02:57 -03:00
nesquena-hermes
5ff16c59d4 fix(streaming): catch OverflowError in _terminal_turn_duration so a huge/invalid pending_started_at can't abort terminal-error settlement
Codex gate on #6312 found the extracted helper caught only (TypeError, ValueError)
while the inline code it replaced caught Exception; float(10**400) raises
OverflowError, escaping the helper and aborting terminal-error cleanup before the
error message is persisted / apperror is emitted. Widen _terminal_turn_duration's
float() guard to (TypeError, ValueError, OverflowError) — the exact exceptions
float() can raise. Adds 10**400 to the existing invalid-origin parametrize
(red-before-green verified).
2026-07-21 20:06:17 +00:00
b3nw
9bf876598d fix: stamp post-run agent.model + one model label per turn (#6068 gate)
Addresses the three gate-review blockers on #6113:

- Fallback attribution: read the served model from agent.model AFTER the
  run (the agent mutates it when a fallback fires) instead of the pre-run
  resolved_model, for both the persisted _usedModel and SSE used_model.
- Gateway double-label: suppress the additive chip whenever
  _gatewayRouting is present, and pass msg._usedModel to
  _formatGatewayModelLabel as its model fallback when routing omits
  used_model.
- Transparent double-label: skip the generic .msg-used-model-inline chip
  when the transparent turn footer owns the model label (.lf-model).

Also per Greptile: settled footer now orders the model chip after the
duration (elapsed - model - ...) to match the transparent footer, and the
test file gains its trailing newline.
2026-07-21 16:02:50 +00:00
b3nw
30aef85b18 feat: stamp per-turn used model in footer stats (#6068)
Record resolved_model on assistant messages as _usedModel, expose used_model
in the usage SSE payload, and render model chips in transparent and settled
turn footers while suppressing duplicates when gateway routing already shows
used_model.
2026-07-21 16:02:49 +00:00
Frank Song
9bde2cf3af fix: reject future terminal turn origins 2026-07-21 15:05:17 +08:00
Frank Song
de96c1d384 fix: harden terminal error worklog settlement 2026-07-21 15:05:17 +08:00
nesquena-hermes
c0d9d81512
Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) (#6387)
* feat: add option to hide new-chat welcome panel

* Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai)

---------

Co-authored-by: vaidu-ai <im@vaidu.net>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-21 04:45:11 +00:00
nesquena-hermes
48b40d930a
Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379)
* fix(profiles): isolate Hermes home per streaming turn

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.3-codex-spark

* fix(profiles): gate skill isolation by capability

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.3-codex-spark

* fix(profiles): harden fallback lock boundaries

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol

* test(profiles): adapt streaming isolation harness

Assisted-by: Codex:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol

* Release: isolate Hermes home per streaming turn (#5877, @starship-s)

---------

Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-21 03:48:00 +00:00
nesquena-hermes
a725a0f71c
Release p1-batch: session cache cap 300→100 (#6362) + virtualization comment (#6318) (#6375)
* perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155)

* fix: revert DOM virtualization default to opt-in, fix gate RED (#6155)

* fix(#6351): lower default session cache cap

* Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318)

---------

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-21 02:53:43 +00:00
nesquena-hermes
302843c89f
Release: compact completed image tool-results (#6315, @sjungwon03) (#6371)
* fix(session): compact completed native vision results

* streaming: guard image-part compaction against unhashable part.type (isinstance str) + regression test [maintainer fix on @sjungwon03 #6315]

* docs(changelog): stamp #6315 completed-image tool-result compaction

---------

Co-authored-by: sjungwon03 <sjungwon03@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-20 20:11:29 +00:00
nesquena-hermes
c414c39f10
feat(extensions): token-v1 proxy→sidecar authentication boundary (#6331)
* feat(extensions): token-v1 proxy->sidecar auth boundary

Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every
proxied request; sidecars validate it. Closes the hole where a loopback sidecar
port is reachable by any local process and cannot distinguish a proxied request
from a direct one.

- api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint,
  re-read-verified so an unpersisted token is never injected, per-request
  mtime-cached read for live rotation, path-escape-safe)
- manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed
- proxy: inject token, strip inbound + response x-hermes-*, auth-off posture
  (loopback-only local_unprotected, else 503), fail-closed when token unavailable
- consent-time auth_required in status payload; mint-on-consent
- docs/EXTENSIONS.md proxy_auth section
- 7 new tests (26/26 green)

* fix(extensions): address Codex+Fable gate on token-v1 (6 findings)

- align token-module extension-id grammar with core _EXTENSION_ID_RE (was
  narrower -> legally-named ext consented then 503'd forever)
- resolve token dir dynamically (mirror _extension_state_dir) -> real test
  isolation, no import-time STATE_DIR cache
- cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber)
- rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) +
  re-fingerprint after read -> same-size/mtime replacement no longer stale
- validate token format on read (url-safe, 16-256) -> malformed file can't
  leak via a ValueError echoed in a 502
- consent fails 503 when token can't be provisioned (was silent-swallow ->
  persisted consent then 503 forever)
- rename status auth_required -> posture enum (protected|local_unprotected):
  nothing is blocked for loopback, so 'required' was misleading
- panels.js: render local_unprotected warning on the consent row (+ CSS)
- docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance
- tests: route-level token-injection+response-strip test; fix illusory isolation
  in token-module test; 27/27 green

* fix(extensions): close 2 token-mint races (Codex re-gate round 2)

- mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final
  path is never observed empty/half-written — a concurrent loser can no longer
  read an empty token file and 503
- single _stable_read helper (fingerprint-read-refingerprint, bounded retry on
  mid-read change) used by BOTH ensure_token and current_token — a token that
  changes during the read is never returned or cached stale
- stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27

* fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3)

os.replace fixed empty-file exposure but still clobbered cross-process: two
processes could both write+replace and a reader between them got a token no
longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail
idiom (session_recovery.py:627): write temp -> link into place (fails if a
winner already published) -> loser drops its temp and reads the winner via
_stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token,
all matching disk. 27/27 tests green.

* fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator

* fix(extensions): finish Frank and Greptile sidecar review

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-20 17:49:23 +00:00
nesquena-hermes
33d55ec897
Release: raster-data-URI redaction fast-path (#6311, @inch772) (#6367)
* fix(redaction): skip native raster data URIs

* fix(redaction): accept mixed-case raster MIME types

* fix(redaction): validate complete raster payloads

* docs(changelog): stamp #6311 raster-data-URI redaction fast-path

---------

Co-authored-by: Su Ahn Lee <11433303+inch772@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-20 16:30:04 +00:00