* 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>
- 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
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).
The suite failed 6 tests (test_sprint12 default-model, test_issue1538 Nous
catalog, test_issue1567 picker capacity/symmetry) whenever it ran on a box that
exports HERMES_MODEL (e.g. inside a live Hermes agent session where HERMES_MODEL
is the agent's runtime model). Root cause: get_effective_default_model() in
api/config.py treats HERMES_MODEL / OPENAI_MODEL / LLM_MODEL as the
highest-priority default-model source — above the isolated test config — so a
runner-exported value overrode the sandbox config and polluted the model
picker/catalog (e.g. injecting "opus-4.8" and breaking the @nous: prefix
invariant / default-model readback).
Fix: scrub the three model-selection env vars at conftest module level (before
any product module imports, covering in-process tests) and again in the
out-of-process test_server subprocess env, and add them to the per-test
_scrub_provider_env helpers in the two Nous test modules. The isolated config /
HERMES_WEBUI_DEFAULT_MODEL is now authoritative regardless of the runner env.
Verified: with HERMES_MODEL=opus-4.8 set, the full suite goes from 6 failed to
0 failed (13550 passed); non-vacuity confirmed (disabling the scrub reproduces
the failures).
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix: Fix height of consolidated kanban board
There was a lot of empty space below the kanban board columns
in the condolidated view (particularly on desktop). Modify the
CSS such that the consolidated view always fills the viewport
vertical space.
* Release: consolidated Kanban board fills vertical space (#6308)
---------
Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* 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>
* fix: preserve live anchor run identity
* Validate envelope run ids before snapshot cursor use
* docs(changelog): stamp #6201 stable Anchor run identity
---------
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(webui): keep the content search from evicting the user's working set
/api/sessions/search?content=1 walks EVERY session and pulls each one through
get_session(), which inserts it into the SESSIONS LRU and marks it
recently-used. On any install with more sessions than sessions_cache_max
(default 300), a single search therefore flushes the whole cache and refills it
with sessions the user is not looking at — the classic buffer-pool
scan-pollution problem. The sessions actually open in the UI are exactly the
ones evicted, and the search is keystroke-debounced, so it repeats while typing.
A scan reads each session exactly once, so nothing it touches has earned
"recently used". get_session_for_scan() reuses a resident session without
promoting it, and reads a cold one straight from disk without caching it. It
returns None rather than raising, since a scan skips what it cannot open.
This is a correctness fix for cache behaviour, not a latency fix. The
multi-second searches that led here were contention, not scan cost: a trivial
/api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real
sessions measures ~4s total.
test_sessions_search_depth_validation patched api.routes.get_session. With the
search reading through the scan accessor it now patches get_session_for_scan —
left unfixed, two of its cases fail and the third passes vacuously against an
empty result set.
Validation:
pytest tests/test_issue4765_sessions_lru_eviction.py
tests/test_sessions_search_depth_validation.py -> 13 passed
Both added eviction tests fail on the pre-fix accessor (verified by revert):
the working set drops from 4/4 to 0/4 resident after one scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): stamp #6084 content-search working-set preservation
---------
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat(terminal): broadcast output to every viewer instead of one shared queue
TerminalSession.output was a single queue.Queue read destructively by the SSE
handler. Two tabs/windows viewing the SAME session each open their own
EventSource, so two _handle_terminal_output handlers competed on that one queue:
every PTY chunk was delivered to exactly one of them. Each tab saw a disjoint
half of the byte stream, and only one ever received terminal_closed.
Output now fans out, mirroring StreamChannel/SessionChannel: each SSE consumer
subscribe()s its own queue (seeded with a bounded backlog so a first/late attach
still replays the recent scrollback, preserving the old buffer-until-first-
consumer behaviour), and put_output broadcasts to all subscribers. A slow
viewer's queue drops its own oldest chunk (drop-oldest, isolated per subscriber)
so one lagging tab can't starve another. The handler unsubscribes in a finally
so the subscriber list can't grow.
Stacked on the terminal fd-leak fix (same file).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cover terminal broadcast lifecycle
* fix: serialize terminal subscriber fanout
* test: cover terminal unsubscribe publication race
* restore timing-flaky pytest.skip on test_terminal_survives_short_lived_request_thread (keep API-updated body); changelog #5836
---------
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>