Opus final gate reproduced two issues in the config-expansion hardening:
MUST-FIX — import-order forward reference. _expand_env_vars (config.py top) calls
_thread_local_env_value, but that fn + _thread_ctx were defined ~6900 lines later,
AFTER the import-time reload_config(). A ${VAR} in a startup config.yaml raised a
(swallowed) NameError, leaving import-time config consumers with empty config.
Fix: relocate _thread_ctx + _thread_local_env_value above the config-file section
(they depend only on os + threading). Verified: startup ${VAR} now expands, no
NameError.
SHOULD-FIX — reload_config baked block-scoped expansion into the process-global
_cfg_cache. If a reload fired while a readonly/worker scope was active (profile
alternation resolves _get_config_path to the named profile), under-expanded
literal ${VAR}s were written to the shared cache, starving concurrent readers of
the `cfg` alias. Fix: reload_config now expands the global cache with the
block flag forced off (process-env view), since the cache is process-global;
per-request scoped reads still go through _thread_local_env_value at their own
sites. Verified: global cache holds the process-expanded value, not the literal.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate found a config-template leak vector distinct from the env-name
scrub: _expand_env_vars() expanded ${VAR} references in config values via raw
os.environ, so a profile config.yaml of e.g. `api_key: ${ANTHROPIC_TOKEN}` let
_get_provider_api_key reconstruct the server-process token and pass it through as
the API key argument — even when the child-env scrub stripped the env var. This
bypassed the thread-local channel entirely.
_expand_env_vars now substitutes via _thread_local_env_value (the same
thread-local-first accessor with the block_process_env_fallback guard the PR
introduced): a ${VAR} resolves to the profile's own value, and refuses the
process-env fallback when a profile-scoped readonly/background scope set the block
flag (leaving the reference unexpanded). Cache integrity preserved — only the RAW
unexpanded dict is cached; expansion re-runs per read against the current thread's
scope. Normal (no-scope) behavior unchanged. + regression that fails without the fix.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Two-layer perf fix for the /api/reasoning storm dropping streaming to ~2 tok/s:
- ui.js: syncReasoningChip() serves the cached chip on routine topbar syncs, only
re-fetching when the model/provider key (_lastReasoningFetchKey) changes — restores
pre-a9ce2889 behavior while keeping its model-switch-refresh intent.
- config.py: memoize _load_yaml_config_file keyed on (path, st_mtime_ns, st_size) under
a lock; cache the RAW parsed dict and re-run cheap _expand_env_vars per call (cache stays
correct vs os.environ and read-modify-save callers get a fresh structure; on-disk edits
picked up via mtime/size). Code byte-faithful from PR head f868f10a1ad7. 9 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
rodboev. Default-off; OFF path byte-identical to master (cache key 'u' + _renderUserFencedBlocks
unchanged when window._renderUserMarkdown falsy). ON routes user text through the same renderMd
SAFE_TAGS/_isSafeUrl sanitizer as assistant msgs. Nathan override-approved (likes the idea, opt-in).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Converged both bounce items: enrich-existing-only (continue on webui_list-is-None,
no new providers -> routing side-effect gone, routes.py no longer touched) + respects
per-provider ID prefix convention (@nous:). Co-authored-by: kaishi00 <kaishi00@users.noreply.github.com>
Codex ship-gate (SILENT): both disk loaders dropped `aliases` (the save path
never persisted them), so a disk-cache hit — strict cold-path OR the new stale
fallback — served /api/models with empty aliases, silently breaking `/model
<alias>` slash-command resolution (static/commands.js resolves slash aliases
only from /api/models.aliases). Add _model_aliases_from_config() helper and
reconstruct aliases from current config in BOTH loaders (fixes the pre-existing
strict-loader gap too). Adds a regression test.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Applies Opus ship-gate findings: (1) the autouse fixture now points
_get_models_cache_path at an isolated temp file so the over-budget static-fallback
test can't read a real ~/.../models_cache.json and become an order-dependent flake;
(2) _load_stale_models_cache_from_disk rejects a cross-schema cache (its groups/badge
shape may be incompatible with the current picker). Adds a cross-schema-rejection test.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Codex SILENT finding: the new 'if not target.exists(): return {}' guard ran
BEFORE the ambient-resolver short-circuit, so a matching ambient profile home
whose directory does not physically exist (fresh install / monkeypatched cfg)
returned {} instead of deferring to get_config() — breaking in-memory overrides
and changing the default-profile missing-home path. Move the guard below the
ambient short-circuit; add a regression test (matching-but-nonexistent ambient
home still defers to get_config()).
Virtualization (#500/#4214) caused a scroll-up flicker on long sessions with
tall tool-call rows (variable-height anchor oscillation), distinct from the
stream-completion jump fixed in #4325. Per maintainer decision, flip the
'Virtualize long transcripts' preference to experimental/opt-IN (default OFF)
for everyone until the scroll behavior is reliable.
- api/config.py: virtualize_transcript default True->False; add
virtualize_transcript_optin marker (both bool-allowlisted); load_settings
force-off migration — a stored true WITHOUT the post-flip opt-in marker is
reset to off, so 100% of existing users land on off; an explicit opt-in
(marker present) is honored.
- boot.js / panels.js: default-off semantics (===true), persist the opt-in
marker on enable, checkbox not pre-checked.
- index.html / i18n.js (en): relabel experimental + flicker warning.
- Tests: rewrite the #4325 toggle assertions for the opt-in contract + 4 new
migration tests (unset/stale-true/explicit-optin/marker-without-true).
Phase B (root-cause scroll-stability) tracked separately. Thanks @b3nw.
Adds a Preferences setting 'Virtualize long transcripts' (default ON, opt-out).
When disabled, _currentMessageVirtualWindow returns a full non-virtualized window
so the entire transcript renders at once (also short-circuits the virtual
re-measure loop). Server-persisted (virtualize_transcript bool, default True),
hot-applies via renderMessages on toggle. i18n in all 13 locales.
Pairs with @rodboev's #4328 stream-end anchor scroll fix (cherry-picked into
this branch) to fully resolve#4325.
Independent review (APPROVED clean) + greptile both flagged one non-blocking nit:
_is_known_model_provider swallowed a transient _is_plugin_model_provider failure
at debug level, so a real plugin-backed provider could briefly vanish from the
picker with no visible log. Promoted to logger.warning(exc_info=True).
Also corrected the endpoint-error-hint comments in ui.js: they cited #4253, which
is actually a cron release (mislabel carried from #4279's body). Comment-only —
no logic change to the browser-verified collapsible-groups code.
Fixes the Photon phantom-providers regression from #4247 (v0.51.461): the
credential-pool detection loop added every auth.json credential_pool key to
detected_providers without checking it names a model provider, so the Photon
iMessage plugin's photon/photon_project/photon_user messaging credentials each
rendered as a phantom provider carrying the full auto-detected model catalog
(251 models on the reporter's machine).
Gated on a new _is_known_model_provider() check (custom:* slug, static
_PROVIDER_DISPLAY/_PROVIDER_MODELS entry, or registered model-provider plugin)
on both the load_pool and raw-dict fallback paths; the group-builder's generic
fallback no longer paints an unrecognized id with the global catalog. Real
pool-keyed providers (copilot, aliased google->gemini) still surface.
Integrates the stronger of two independent same-fix PRs — #4330's implementation
(thorough docstring, try/except-hardened plugin check, preserved #1881 comment,
defensive else branch) + its 8-case test that mocks at the WebUI boundary
(stubs hermes_cli, never imports the bundled agent package that's absent in CI,
exercises both the load_pool and ImportError raw-dict paths) — over #4329's
2-case test. Both rodboev (#4329) and the maintainer agent (#4330) independently
found and fixed the same regression.
Reported by Nic.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex gate caught a cross-profile leak: _CREDENTIAL_POOL_CACHE was keyed
by provider id alone, so a custom provider configured under profile A made
profile B report has_key=True from a stale cache hit (then 401 at request).
Key the cache by (active-profile auth-store path, provider id) at all
get/set/evict sites via _credential_pool_profile_tag(). Also make the two
new bare _entry.runtime_api_key reads getattr-safe (OAuth-only entries lack
the attr), matching the guard the author already applied at providers.py.
Adds a cross-profile isolation regression test.
Co-authored-by: akrhin <akrhin@users.noreply.github.com>
Custom/aliased providers with keys in the credential pool (added via
hermes auth add) were reported unconfigured in the WebUI because provider
detection only checked env vars. Add _has_explicit_pool_credentials() that
checks the pool while filtering the ambient gh_cli entry (so Copilot doesn't
falsely show configured on any machine with gh installed), cache-routed to
avoid the cold per-provider load_pool latency, and resolve provider aliases
before load_pool() at every retrieval site so detection and retrieval read
the same pool (glm->zai, github->copilot, etc.). /api/models/live wrapped in
profile scope; getattr-safe runtime_api_key for OAuth-only entries.
Co-authored-by: akrhin <akrhin@users.noreply.github.com>
Approve/deny on a remote-gateway backend now forwards to the gateway runs API
(run_id server-derived, existing auth+CSRF preserved, capability-probed + 60s
cached). Non-gateway/local approval path unchanged — relay only fires when a
session has an active gateway run (_STREAM_RUN_IDS). Old gateways get a clear
upgrade message instead of a silent no-op.
Co-authored-by: rodboev
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* stage-3712: model picker show-all overflow + fix per-row count leak (#3691)
Self-rebased rodboev's #3712 onto master. FIX (Nathan-caught): the per-row
provider chip stripped the '(N of M)' overflow count — that count belongs on the
group heading only, and was reading as nonsense post-expand (row 30 showing
'15 of 30'). Updated test to assert heading-carries-count + rows-do-not.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* stage-3712: + dedupe overflow expand (Codex SILENT) — reuse existing injected option
Codex caught: select hidden overflow model from search → reopen → Show all could
duplicate the row (_appendOverflowOptionsToGroup blindly appended every extra
model without checking the select for an already-injected option). Now moves/reuses
an existing same-value option instead of re-creating it.
* stage-3712: + fix expanded-heading double-count (Opus) — strip decoration, count rendered rows
Opus caught: post-expand the heading showed 'Nous (2 of 4) (4)' (appended its own
count onto the already-decorated backend label) and used modelCount (incl hoisted-
configured) vs rendered rows. Now strips the '(N of M)' suffix when expanded +
counts groupRows.length. Test asserts no double count on out[expanded].
* stage-3712: + include extra_models in Settings/Cron/Profile/Aux native selectors (Codex CORE)
Codex caught: the server overflow split moves models>15 into extra_models for ALL
providers, but the 4 native <select> builders in panels.js (settingsModel:6690,
cronFormModel:1112, profileFormModel:5784, _auxProviders:8287) read g.models only
— silently dropping the overflow tail for large providers. Now concat
models+extra_models at all 4 sites. Test guard added.
* Release PG: model picker show-all overflow for large provider catalogs (#3691)
Self-rebased rodboev's #3712 onto v0.51.445; full browser-tested (desktop+mobile,
before/after) + Nathan screenshot-approved. 4 gate-caught issues fixed inline:
per-row count leak (Nathan), overflow-expand dedup (Codex), heading double-count
(Opus), and native Settings/Cron/Profile/Aux selectors dropping the overflow tail
(Codex CORE). Codex SAFE + Opus SHIP + 41 picker tests green.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(#3988): default show_cli_sessions to true for new installs, grandfather existing OFF
Combines the best of #3997 (rodboev) and #4222 (Sanjays2402):
- config.py default flipped True so NEW installs surface CLI/TUI/Telegram/
Discord/Hermes One sessions in the sidebar without hunting for the toggle.
- load_settings() backfill grandfathers ESTABLISHED installs OFF: when the saved
settings predate the flip (onboarding_completed True + key absent), pin False so
an existing user's sidebar doesn't silently change. Fresh installs get True.
- Default-true hydration at every read site (config.py / boot.js / panels.js
checkbox) via the !==false idiom — no config-ON-but-UI-OFF mismatch (#4006 class).
- 10 tests: config default, bool-key round-trip, grandfather matrix
(fresh/mid-onboarding/established-absent/explicit-true/explicit-false/
save-preserves-grandfather), and both frontend hydration sites.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix(#4217): guard /api/file/delete and /api/file/rename against symlink targets
* chore(release): stamp v0.51.427 (Release ON) for #3988 + #4217
* chore: drop unused imports (ruff F401) in absorbed test files
* fix(#4217,#3988): address review — dangling-symlink ordering + boot.js catch fallback
Greptile review on #4230 + the related-PR reviews surfaced two fixes on the
shipped code:
1. (#4217) _handle_file_delete/_handle_file_rename checked target.exists()
BEFORE is_symlink(), so a DANGLING workspace symlink resolved to a missing
target -> misclassified as 404 'File not found' and left permanently
undeletable/unrenameable. Moved the no-follow is_symlink() guard before the
follow-based exists() check in both handlers. +2 dangling-symlink tests.
2. (#3988) boot.js settings-load-FAILURE catch block hardcoded
_showCliSessions=false, inconsistent with the new True default (the #4006
catch-block-fallback class). Now defaults true. +1 test pinning the fallback.
Both confirmed by the new tests (20/20 in the two files).
* fix(#3988,#4217): address Opus review — broaden grandfather signal + move-handler parity
Opus #4230 ship-review surfaced 2 LOW findings, both now fixed in-line:
1. (#3988) The grandfather keyed only on onboarding_completed, so a CLI-configured
user who tweaked a WebUI setting but never ran the wizard would get flipped ON
(and onboarding.py later auto-persisting onboarding_completed=True would make it
permanent). Now: established = show_cli_sessions absent AND (onboarding_completed
OR any persisted setting other than a falsy onboarding flag). A genuinely new /
still-mid-onboarding file still falls through to True. +1 test.
2. (#4217) _handle_file_move still checked exists() before is_symlink() — the same
dangling-symlink-404 bug just fixed in delete/rename. Reordered for parity so a
dangling symlinked source is rejected 400, not 404.
22 tests across the two files pass; ruff clean.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix: keep custom provider when model id has known-provider prefix (#4210)
Siblings of #3872 (v0.51.349, custom provider with a configured base_url
preserves intrinsic routing prefixes). The base_url unset variant of
the same bug was missed: with provider=custom or custom:<slug> and no
base_url set, a model id like 'x-ai/grok-2' or 'google/gemma-2' was
hijacked to provider='openrouter', base_url=None. The backend then
failed to initialize with 'RuntimeError: No LLM provider configured'
because the user had no OpenRouter credentials configured.
resolve_model_provider() now applies the same _is_custom exemption the
config_base_url branch already carries to the cross-provider redirect
at the end of the slash branch. The custom provider keeps the request
and the full model id; real first-party providers pointed at an
OpenAI-compatible proxy (e.g. provider=openai + base_url=litellm) are
unaffected because their strip path is in the base_url branch and the
'prefix == config_provider' branch above the change.
Verified:
- test_model_resolver.py 32 passed (30 existing + 2 new RED tests)
- test_custom_provider_bare_model_reasoning.py 34 passed
- Pre-existing F401 (api/config.py:23,24,28) are not introduced by
this change; same 3 errors exist on upstream/master.
Refs #4210
* chore(release): reposition #4210 CHANGELOG entry under v0.51.426 (OM)
---------
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat(workspace): add Todos tab to workspace panel behind a setting (#2942)
* fix(workspace): use DOM attribute to check active tab across script scope
* Prefer the live todos snapshot in the workspace tab
* Fill the missing workspace Todos locale keys
* feat(workspace): add optional Todos tab to workspace panel (#3564)
Rebased rodboev's #3564 onto current master + CHANGELOG entry.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3564): apply workspace Todos tab visibility on cold boot
Codex gate: boot.js set window._workspaceTodosTab from saved settings but
never called _applyWorkspaceTodosTabVisibility(), so a previously-enabled
Todos tab stayed hidden on a normal page reload (only re-appeared after
opening Settings). Call the visibility helper after assigning the flag in
both the settings-apply and the fallback/default paths.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.422 (Release OI) for #3564
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(#3750): preserve LM Studio auth on reasoning probe
Rebased rodboev's #3837 onto current master (master reworked
resolve_model_reasoning_efforts via #3431 with a combined hermes_cli
import + _nested_route_reasoning_denied guard). Threaded the api_key
resolution + the _lmstudio_model_reasoning_options wrapper + the
HTTP-fallback probe into master's current structure; intent preserved
byte-identically (+164/-24, same as the original PR diff).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3837): gate LM Studio probe credential to configured endpoint + no-redirect opener
Codex security review (CORE) on the rebased #3837: GET /api/reasoning accepts
a caller-supplied base_url, and the new probe attached the configured LM Studio
API key as a Bearer token; urllib also re-sends Authorization across redirects.
Together that could exfiltrate the stored credential to an attacker-controlled
host. (master only escalates here because master probed WITHOUT a key.)
Fixes:
1. Only forward the LM Studio credential when the probe target normalizes to
the configured LM Studio base URL (reuses _normalize_base_url_for_match);
a caller-supplied non-matching base_url is probed keyless.
2. _lmstudio_reasoning_probe_options_fallback uses a no-redirect opener
(_NoRedirectHandler) so a 3xx can't forward the Authorization header onward.
3. Two regression tests: caller-supplied base_url drops the key; redirect is
not followed (credential never reaches the redirect target).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3837): route ALL credentialed LM Studio probes through the no-redirect probe
Codex re-gate caught that the first fix only hardened the built-in fallback;
in production hermes_cli IS importable, so the preferred path called
hermes_cli.models.lmstudio_model_reasoning_options(..., api_key=...) which uses
plain urlopen and still follows redirects with the credential.
Fix: _lmstudio_model_reasoning_options now short-circuits to the built-in
no-redirect probe whenever api_key is set; the hermes_cli path is used ONLY for
keyless probes (no credential to leak). Reworked tests accordingly:
- test_credentialed_probe_never_calls_hermes_cli: credentialed probe bypasses CLI
- test_keyless_probe_logs_signature_mismatch_before_fallback: keyless CLI degrade
- test_credentialed_probe_does_not_follow_redirects_end_to_end: E2E through
resolve_model_reasoning_efforts with real hermes_cli; redirect target never hit
Verified both security tests fail without the guard and pass with it.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.420 (Release OG) in CHANGELOG
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
get_reasoning_status() returned the raw config reasoning_effort, so a stale saved
'max' surfaced on boot/status/chip even though streaming coerces it to xhigh. Now
routes the status value through coerce_reasoning_effort_for_model() with the resolved
model/provider/base_url, so all read paths agree. Added a stale-max->xhigh status
regression test.
The richer static-catalog builder's group-building loop used a strict item["id"]
extraction that would KeyError (caught → degrade to the minimal one-model catalog)
for legal config shapes where providers.<id>.models is a list of dicts keyed by
"model"/"name" rather than "id". Mirror the tolerant id-or-model-or-name resolution
the detection loop already uses, skip entries with nothing usable, and add a
regression test. No behavior change for the common list-of-strings / list-of-id-dicts
shapes.
docs(changelog): stamp #4052 model-picker budget fallback as v0.51.370 (Release MI)