Commit Graph

1098 Commits

Author SHA1 Message Date
Frank Song 8f3dbe185d fix: consolidate __CACHE_VERSION__ → __WEBUI_VERSION__ (#1509)
__CACHE_VERSION__ (sw.js) and __WEBUI_VERSION__ (index.html) are
functionally identical — both resolve to quote(WEBUI_VERSION, safe='')
at request time. Two names exist for historical reasons (different files
added at different times).

Rename __CACHE_VERSION__ → __WEBUI_VERSION__ in:
- static/sw.js (CACHE_NAME + VQ constant + comment)
- api/routes.py (substitution string)
- tests/test_pwa_manifest_sw.py (all assertions)

Single canonical name. No behavior change — same ?v=vX.Y.Z query strings
on the same URLs.
2026-05-03 14:59:37 +08:00
nesquena-hermes 7921a47f9d Merge pull request #1515 from nesquena/stage-277
v0.50.277 — model-picker shared-reference fix (supersedes #1511)
v0.50.277
2026-05-02 23:50:17 -07:00
Hermes Bot afa7223c1a release: stamp v0.50.277 + Opus SHOULD-FIX (production-path regression guard)
CHANGELOG, ROADMAP, TESTING bumped (3925 → 3929 tests collected).

Opus SHOULD-FIX absorbed in-release: tests #1-3 documented the dedup
contract via direct construction but did not invoke get_models_grouped().
Test #4 (test_get_models_grouped_unconfigured_providers_get_independent_dicts)
inspects the live source for the literal copy.deepcopy(auto_detected_models)
call AND runs an end-to-end smoke of the fixed assignment loop.

A future refactor that removes the deepcopy at api/config.py:2078 will
fail this test immediately.
2026-05-03 06:47:52 +00:00
Hermes Bot 6381ab1b8a fix(model-picker): deepcopy auto_detected_models per group to stop dedup bleed-across (#1511 root cause)
Supersedes contributor PR #1511 (lost9999), which removed the label-suffix
logic in _deduplicate_model_ids() but left the underlying shared-reference
bug intact — IDs would still be silently corrupted across provider groups,
just with cleaner-looking labels.

## Bug shape

When multiple unconfigured providers (Ollama / HuggingFace / custom
endpoints / Google Gemini CLI / Xiaomi / etc.) all fell through to the
'else' branch in api/config.py:get_models_grouped() that ends with:

    groups.append({..., "models": auto_detected_models})

every group ended up sharing the SAME list reference AND the SAME dicts
inside. When _deduplicate_model_ids() then mutated those dicts to add
@provider_id: prefixes and provider-name parentheticals, the changes were
applied to every group that referenced the same dict.

Visible symptom: user 'vishnu' reported the dropdown showing
'Deepseek V4 Flash (Xiaomi) (Ollama) (HuggingFace) (Google-Gemini-Cli)'
on every group. Hidden symptom (worse): the 'id' field collapsed to
'@xiaomi:deepseek-v4-flash' on every group too, so clicking the entry
under any group routed the request to Xiaomi.

## Fix

api/config.py:2078 — wrap auto_detected_models in copy.deepcopy() at the
groups.append site so each group gets its own independent dicts. The
existing _deduplicate_model_ids() logic is correct and unchanged; the
bug was in the assignment site, not the dedup function.

The single-parenthetical disambiguation in labels is retained because
the composer chip (composer-model-label) shows the model label without
the optgroup header context — 'Deepseek V4 Flash (Ollama)' is more
useful than ambiguous 'Deepseek V4 Flash' there.

## Tests

tests/test_issue1511_dedup_shared_reference.py — 3 new tests:
- test_groups_have_independent_model_lists: structural invariant pin
- test_unconfigured_providers_no_shared_dedup_bleed: end-to-end against
  the corrected code path; verifies each group gets its own @provider_id:
  prefix and exactly ONE provider parenthetical per disambiguated label
- test_shared_reference_pre_fix_demonstrates_corruption: documents the
  broken state that motivated the fix

Full suite: 3925 → 3928 passing (+3 new, 0 regressions).

Co-authored-by: lost9999 <56498264+lost9999@users.noreply.github.com>
2026-05-03 06:41:11 +00:00
nesquena-hermes 8ef58cad27 Merge pull request #1510 from nesquena/stage-276
v0.50.276 — SW stale-CSS fix (PR #1508, closes #1507)
v0.50.276
2026-05-02 23:28:34 -07:00
Hermes Bot 2420c6bda3 release: stamp v0.50.276 (PR #1508 — SW stale-CSS fix, closes #1507)
CHANGELOG, ROADMAP, TESTING all updated.
3923 → 3925 tests collected (+2 regression tests).

Pre-release Opus advisor pass: SHIP AS-IS.
Independent review: nesquena APPROVED with end-to-end trace.

Migration note: existing v0.50.275 users will see one more round of
broken styling on first reload after upgrade (old SW serves old
index.html). Subsequent reloads clean. Future upgrades will not
recur because SW pre-cache is now keyed on versioned URL.

Filed follow-up #1509 for __CACHE_VERSION__/__WEBUI_VERSION__
placeholder consolidation (low-priority cleanup, no functional impact).
2026-05-03 06:26:41 +00:00
Hermes Bot d7b34a740e Merge PR #1508: version style.css link so old SW cannot return stale CSS (closes #1507) 2026-05-03 06:20:43 +00:00
nesquena-hermes 4fea813adc fix(sw-cache): version style.css link so old SW cannot return stale CSS (#1507)
Container restart / in-place upgrade left the previous service worker still
controlling open tabs. Its fetch handler intercepted 'static/style.css',
matched the unversioned URL exactly against its old shell cache, and returned
the OLD CSS — while the JS files (which already carry ?v=__WEBUI_VERSION__)
hit the cache as misses and loaded fresh from network. New JS + old CSS
broke the layout until a force refresh bypassed the SW.

Fix is a 1-line attribute change plus aligning the SW pre-cache list:

* static/index.html: add ?v=__WEBUI_VERSION__ to the style.css link, matching
  the pattern already in use for every JS file in the page.
* static/sw.js: add the same ?v=__CACHE_VERSION__ suffix to every versioned
  entry in SHELL_ASSETS so that pre-cache URLs match what the page actually
  requests. Unversioned entries (root, manifest, favicons) stay unversioned.

Tests:

* New regression test_index_versions_stylesheet (lock the href) and
  test_sw_shell_assets_match_versioned_asset_urls in test_pwa_manifest_sw.py.
* test_workspace_panel_preload_marker_restored_in_head in test_sprint37.py
  loosened to match the css link prefix (preserves the ordering invariant).

Verified live on port 8789: served HTML carries
'static/style.css?v=v0.50.275-dirty' and SW SHELL_ASSETS receive the
matching VQ at request time.

Closes #1507.
2026-05-03 06:09:47 +00:00
nesquena-hermes 52226bcdd7 Merge pull request #1506 from nesquena/stage-275
v0.50.275 — /session/static/* MIME-type fix (PR #1505 by @rickchew)
v0.50.275
2026-05-02 22:29:52 -07:00
Hermes Bot 995822ac0d release: stamp v0.50.275 (PR #1505 — /session/static/* MIME-type fix)
CHANGELOG, ROADMAP, TESTING all updated.
3918 → 3923 tests collected (+5 regression tests).

Pre-release Opus advisor pass: SHIP. Path-traversal sandbox confirmed
for literal .. and URL-encoded %2e%2e variants. Auth-exemption benign
(404s any sandbox escape before bytes leak).
2026-05-03 05:25:58 +00:00
Hermes Bot 8f58688b66 test: lock /session/static MIME-type + auth fix; drop unused import
- Add tests/test_session_static_assets.py (5 tests):
  * /session/static/style.css must return text/css (not text/html)
  * /session/static/ui.js must return application/javascript
  * /session/<id> still serves the HTML index (catch-all not weakened)
  * Path-traversal still sandboxed after prefix strip
  * /session/static/* matches /static/* auth-exemption policy
- Drop unused 'from urllib.parse import urlparse as _up' import from
  PR #1505's added block (parsed._replace already gives a usable result).

Co-authored-by: Rick Chew <rickchew@users.noreply.github.com>
2026-05-03 05:20:19 +00:00
Hermes Bot a60273b852 Merge PR #1505: serve static assets correctly under /session/* routes 2026-05-03 05:12:24 +00:00
Rick Chew 7cf2150b94 fix: serve static assets correctly under /session/* routes
When the browser loads a session page at /session/<id>, it requests
static assets relative to that path — e.g. /session/static/style.css.
The /session/* catch-all in handle_get() intercepted those requests and
returned the HTML index page (text/html), causing browsers to refuse the
stylesheet with a MIME-type mismatch error.

Two-part fix:
- routes.py: add a guard before the /session/ catch-all that strips the
  /session prefix from /session/static/* paths and delegates to
  _serve_static(), so the correct Content-Type is returned.
- auth.py: whitelist /session/static/* in check_auth() alongside
  /static/, so static assets on session pages are served without
  requiring an authenticated session (same policy as /static/).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 13:05:15 +08:00
nesquena-hermes 539a72b9e6 Merge pull request #1504 from nesquena/stage-274
Stage 274: PR #1501 — LM Studio onboarding fully fixed (probe + keyless + LM_API_KEY alignment) (closes #1499 #1500)
v0.50.274
2026-05-02 20:34:56 -07:00
Hermes Bot 3837ed8bf1 chore(release): stamp v0.50.274 — LM Studio onboarding fully fixed (#1499 #1500)
PR #1501 closes all three sub-bugs from #1420:
- #1499 (a): probe <base_url>/models before persisting
- #1499 (third sub-bug): keyless setup is a first-class state for self-hosted providers
- #1500: webui env var aligned with agent CLI's canonical LM_API_KEY

Backed by 60+ regression tests (38 new + 22 updated). Pre-release Opus
advisor pass: ship-ready. Independent review by nesquena: APPROVED with
4 non-blocking observations (1 fixed in-release, 3 deferred to follow-ups
#1502 + #1503 + future helper extraction).

Closes #1499, closes #1500.
Refs #1502 (legacy alias sunset tracking), #1503 (probe re-render UX papercut).
2026-05-03 03:33:07 +00:00
Hermes Bot e7a19d2754 Stage 274: PR #1501 — onboarding probe + keyless setup + env-var alignment (#1499 #1500) 2026-05-03 03:24:00 +00:00
Hermes Bot ba6f34488e fix(onboarding,probe): refuse HTTP redirects on probe path (reviewer-flagged on PR #1501)
SSRF defense-in-depth: `urllib.request.urlopen` follows redirects by default,
so a probe at `http://example.com/v1/models` could be redirected to
`http://internal-service:8080/admin` — surfacing internal HTTP services to
the authenticated user. The probe is already gated behind WebUI auth and the
local-network check, so the practical attack surface is 'authenticated user
enumerating internal services' (same as `curl` from their browser DevTools).
Tightening the redirect default is cheap insurance.

Implementation:

- New module-level `_NoRedirectHandler` (subclasses `urllib.request.HTTPRedirectHandler`,
  overrides `redirect_request` to return None — urllib then raises `HTTPError(3xx)`
  rather than following).
- New module-level `_PROBE_OPENER = urllib.request.build_opener(_NoRedirectHandler())`.
- `probe_provider_endpoint` switches from `urlopen(req, …)` to `_PROBE_OPENER.open(req, …)`.
- The existing `HTTPError` handler now categorizes 3xx as `unreachable` with a
  detail string mentioning 'redirect' so the user understands what happened.
  3xx does NOT get its own error code in `PROBE_ERROR_CODES` — the error
  taxonomy contract stays the same shape (frontend i18n unchanged).

Added regression test `test_probe_does_not_follow_redirects` in
`tests/test_issue1499_onboarding_probe.py`. Spins up a tiny HTTP server that
302-redirects `/v1/models` to `/different-endpoint` (which would return
`{'data': [{'id': 'should-not-see'}]}` if followed). Asserts the probe
returns `{ok: False, error: 'unreachable', status: 302, detail: …'redirect'…}`
and that the 'should-not-see' string never appears in the result.

Mutation-verified: reverting `_PROBE_OPENER.open` back to `urlopen` causes
the test to fail with "Probe followed a redirect — should have refused".

Suite delta: 3917 → 3918 passing (+1).

Reviewer-flagged in PR #1501. Per the
'reviewer-flagged-fix-in-release-not-followup' policy: <20 LOC defensive
fix, regression test path obvious, ship in this release rather than punting.
2026-05-03 03:21:22 +00:00
Hermes Bot 8f4692b8cf fix(onboarding): allow keyless setup for self-hosted providers (#1499 third sub-bug)
Pre-fix, the wizard rejected an empty api_key for every provider in
_SUPPORTED_PROVIDER_SETUPS — including lmstudio, ollama, and custom,
which run keyless on the vast majority of local installs. The agent's
LMSTUDIO_NOAUTH_PLACEHOLDER substitution at chat-time was the workaround
for the no-auth case, but the wizard side rejected the empty input first.
Users had to type random gibberish into the API key field to clear the
form — the third sub-bug from #1420 that the prior commit's PR description
explicitly punted to a follow-up.

Surfaced by Nathan during PR review: "I think it's too weird for users
to have to type a string into the API key field, right?"  Yes — and the
probe (#1499) makes the cleanest fix strictly better: we accept empty
keys, and the probe gives instant feedback ("Connected. 2 model(s)
available." for keyless servers, "401" for auth-required servers).

Backend changes
---------------

* `api/onboarding.py` — `_SUPPORTED_PROVIDER_SETUPS` gains
  `key_optional: True` for `lmstudio`, `ollama`, `custom`. Cloud
  providers (openrouter, anthropic, openai, gemini, deepseek, …)
  remain key_required.

* `apply_onboarding_setup` skips the "{env_var} is required" check
  when `key_optional` is set AND no key is supplied. No write to .env
  for the empty-key case (no `LM_API_KEY=*** placeholder lying in the
  user's .env`).

* `_status_from_runtime` reports `provider_ready=True` for key_optional
  providers based on `requires_base_url` alone, so the wizard doesn't
  refire on the next page load just because there's no api_key. Cloud
  providers still need a key for provider_ready=True.

* `_build_setup_catalog` exposes the `key_optional` flag to the frontend.

Frontend changes
----------------

* `static/onboarding.js` — new `_renderOnboardingApiKeyField()` helper.
  For key_optional providers:
    - Label: "API key (optional)"
    - Placeholder: "Leave blank for keyless servers"
    - Inline italic muted help: "Most LM Studio / Ollama / vLLM installs
      run keyless — leave this blank if your server doesn't require
      authentication. Use the Test connection button to verify."
  For cloud providers: unchanged (label "API key", standard placeholder,
  no help block).

* The api-key input also now triggers `_scheduleOnboardingProbe()` on
  oninput, so changing the key re-runs the probe — handles "the server
  rejected my empty key with 401, let me add one and retry."

* `static/i18n.js` — 3 new keys × 9 locales (canonical English in `en`,
  English fallback with `// TODO: translate` markers in the other 8).

* `static/style.css` — `.onboarding-api-key-help` rule for the muted
  italic helper paragraph.

Verified end-to-end on port 8789
--------------------------------

Spun up an isolated test server + a mock LM Studio at
`127.0.0.1:11234/v1/models`. Stepped through the wizard:

* Picked LM Studio → field label flipped to "API key (optional)",
  placeholder showed "Leave blank for keyless servers", help text
  rendered in italic muted gray below.
* Switched to Anthropic → label reverted to "API key", help text
  disappeared. Visual hierarchy correct.
* Left api_key blank, set base_url to the mock, clicked Test connection
  → green "Connected. 2 model(s) available." banner. Probe-discovered
  models populated the workspace-step dropdown.
* Continued through to the finish step. config.yaml written with
  provider/model/base_url. **`.env` does NOT exist** — no placeholder
  string written. `chat_ready: true`, `state: ready`.
* Vision tool confirmed the visual hierarchy: subtle italic help
  reads as documentation, prominent green banner pops as status.

Tests
-----

`tests/test_issue1499_keyless_onboarding.py` — 16 tests in 3 classes:

  TestKeyOptionalProviderSchema (5)
    - lmstudio / ollama / custom declare key_optional=True
    - openrouter / anthropic / openai do NOT (regression defense)
    - setup catalog exposes the flag

  TestKeylessOnboarding (6)
    - lmstudio / ollama / custom: empty api_key accepted, no .env write
    - openrouter / anthropic: empty api_key still rejected
    - lmstudio with explicit key still writes .env (regression defense)

  TestKeylessChatReady (5)
    - lmstudio / ollama: provider_ready=True with no key
    - custom: provider_ready=True with key+base_url, False without base_url
    - openrouter: provider_ready=False with no key (regression defense)
    - End-to-end get_onboarding_status reports chat_ready=True

Full suite: 3901 → 3917 passing (+16 from this commit; +22 cumulative
from the PR's earlier commit). 0 failures.

Closes #1499 (all three sub-bugs from #1420 now addressed)
2026-05-03 03:07:07 +00:00
Hermes Bot 8616033605 fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.

#1499 — Onboarding wizard probes <base_url>/models before persisting

Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.

Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
  in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
  Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
  on failure with stable error codes the frontend can switch on:
  invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
  unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
  pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
  function above. Same local-network gate as `/api/onboarding/setup`
  because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
  ends up in config.yaml; the probed model list just populates the
  wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
  gated behind WebUI auth and the legitimate target IS a local LM Studio
  / Ollama / vLLM server. A "block private IPs" SSRF defense would make
  the feature useless for its primary use case.

Frontend:
* `static/onboarding.js`:
  - New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
  - `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
    & cached on (provider, baseUrl, apiKey).
  - Debounced (400ms) on `oninput` of the base URL field.
  - Explicit "Test connection" button.
  - `nextOnboardingStep` blocks Continue at the setup step for any
    provider with `requires_base_url=True` until the probe succeeds.
    Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
  English fallback with `// TODO: translate` markers in the other 8 —
  same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
  error variant, green-tinted success variant, neutral probing state).

Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
  hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
  populates from the probed list, Continue advances normally.

#1500 — webui env var aligned with agent CLI (LM_API_KEY)

The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.

Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:

* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
  - `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
  - `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
    pre-#1500 users so detection keeps working without forcing an
    .env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
  — new dict, used by `_provider_has_key` and `get_providers`'s
  key_source resolution. Drops in cleanly when other providers later
  rename their env vars too.

Verified:

```
before fix:  webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
 after fix:  webui writes LM_API_KEY → agent picks it up → chat works
             pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
                                                  → key_source='env_file'
```

Tests

* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
  3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
  success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
  wrong-shape, api_key authorization header passthrough, "probe must
  not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
  3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
  onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
  onboarding writes ONLY the canonical name, legacy env var still
  detected post-migration, canonical takes precedence when both are
  set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
  the original 5-test #1420 suite now pins LM_API_KEY as canonical
  and LMSTUDIO_API_KEY as alias.

Full suite: 3879 → 3901 passing (+22), 0 failures.

Out of scope (explicitly NOT addressed here)

The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.

Closes #1499
Closes #1500

Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00
nesquena-hermes 9b8d0bac0c Merge pull request #1498 from nesquena/fix/lmstudio-provider-env-var-1420
fix(providers): map lmstudio to LMSTUDIO_API_KEY in _PROVIDER_ENV_VAR (#1420)
v0.50.273
2026-05-02 19:16:13 -07:00
Hermes Bot 7cf9c81a49 docs(release): stamp v0.50.273 — CHANGELOG + ROADMAP + TESTING test counts 2026-05-03 02:15:00 +00:00
Hermes Bot d3c7ac182b fix(providers): map lmstudio to LMSTUDIO_API_KEY in _PROVIDER_ENV_VAR (#1420)
After completing the onboarding wizard with the LM Studio provider, users
saw LM Studio in the model picker and could chat normally, but Settings →
Providers showed no LM Studio entry — or rendered it with has_key=False
and configurable=False even when LMSTUDIO_API_KEY was already in
~/.hermes/.env. There was no UI surface to add or update the key.

Root cause:

api/providers.py:_PROVIDER_ENV_VAR — the dict that maps each provider id
to its env-var name — is missing an "lmstudio: LMSTUDIO_API_KEY" entry.
That dict drives two things:

  1. _provider_has_key(pid) — env-var-based key detection. Returns False
     and sets key_source='none' if the pid isn't in the dict, regardless
     of what's in .env or os.environ.

  2. get_providers() line 364:
        "configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
     Without the entry, configurable=False, hiding the "Add API key"
     form in the UI.

So with no map entry, an LM Studio user with a working LMSTUDIO_API_KEY
gets has_key=False (wrong) AND no UI to fix it (wrong-er).

Same bug shape as #1410 (Ollama Cloud / local Ollama env-var collision).
The #1410 fix dropped bare "ollama" from _PROVIDER_ENV_VAR because
OLLAMA_API_KEY was shared with ollama-cloud and the runtime semantics
made the local key detection ambiguous. LMSTUDIO_API_KEY has no such
collision — it's only consumed by the lmstudio runtime.

Verified via reproduction:

  before fix: lmstudio.has_key=False, configurable=False, key_source='none'
   after fix: lmstudio.has_key=True,  configurable=True,  key_source='env_file'

5 regression tests in tests/test_issue1420_lmstudio_provider_env_var.py:

  1. _PROVIDER_ENV_VAR['lmstudio'] == 'LMSTUDIO_API_KEY'
  2. LMSTUDIO_API_KEY in env → has_key=True + configurable=True
  3. providers.lmstudio.api_key in config.yaml → has_key=True (fallback path)
  4. No env, no config → has_key=False but configurable=True (UI fix surface)
  5. LMSTUDIO_API_KEY doesn't cross-detect any other provider

Mutation-verified: reverting the map entry causes 4 of 5 tests to fail
with clear assertion messages naming the bug (the 5th — config.yaml
fallback — is independent of the env-var path and intentionally remains
green to pin that the existing path keeps working).

Scope discipline:

#1420's broader thread surfaces a sibling bug — the onboarding wizard
never probes the configured <base_url>/v1/models endpoint before
persisting (the wizard accepts unreachable URLs silently with no
model-list dropdown population). That bug is being filed separately
and is NOT addressed here. Adding a probe touches the wizard UX flow,
has timeout / error-handling implications, and warrants its own design
pass.

Closes #1420 (the "LM Studio missing from Settings" half — feature-
request half about provider catalog support is already shipped: LM
Studio has been a first-class provider in api/onboarding.py since long
before this issue).

Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:06:19 +00:00
nesquena-hermes 6c3ff3ff47 Merge pull request #1496 from nesquena/stage-272
Stage 272: 3 PRs — #1493 sidebar cancel + #1495 state.db FD leak fix + #1492 P0 polish bundle (closes #1466 #1469 #1484 #1486 #1494; refs #1458 Bug #2)
v0.50.272
2026-05-02 18:41:16 -07:00
Hermes Bot 4aad62defb chore(release): stamp v0.50.272 — sidebar cancel + state.db FD leak fix + P0 polish bundle (#1466 #1494 #1469 #1484 #1486)
3 PRs in this batch (3866 → 3874 tests, +8):

- #1493 (@dso2ng) — sidebar Stop response cancels row's stream not active pane's (closes #1466, follow-up to #1480)
- #1495 (self-built; reported by @insecurejezza in #1494) — state.db connection FD leak in sidebar polling (closes #1494, addresses Bug #2 of #1458)
- #1492 (@bergeouss) — P0 bugfixes bundle: tool-card args readability + CLI rename persistence + scroll pinning + sw.js relative-path regression test (closes #1469 #1484 #1486)

This release closes Bug #2 of the umbrella issue #1458. Bug #1 was closed by v0.50.269 (#1483) + v0.50.270 (#1487). Bug #3 (HTTP-unhealthy without FD exhaustion) is the remaining work item.
2026-05-03 01:39:44 +00:00
Hermes Bot c4ea9643f9 Stage 272: PR #1492 — P0 bugfixes (tool-card args + CLI rename + scroll pinning + sw.js relative-path regression test) 2026-05-03 01:34:10 +00:00
bergeouss 6d17e55688 fix: revert sw.js to relative path + add regression test
- Revert '/sw.js' back to relative 'sw.js' in serviceWorker.register()
  (static/index.html:50). The dynamic <base href> script resolves
  relative paths correctly for both root and subpath mounts.
  Absolute path breaks reverse-proxy installs at e.g. /hermes/.

- Add regression test test_index_sw_registration_uses_relative_path
  to prevent future absolute-path rewrites from silently breaking
  subpath-mount installs.

Addresses reviewer feedback on PR #1492 (review by @nesquena).
2026-05-03 01:29:41 +00:00
Hermes Bot c12be39cbf Stage 272: PR #1493 — sidebar cancel for running sessions (#1466) 2026-05-03 01:25:57 +00:00
Hermes Bot 1d415220fd Stage 272: PR #1495 — state.db FD leak fix (#1494, Bug #2 of #1458) 2026-05-03 01:25:46 +00:00
Hermes Bot 51a87ebdc7 fix(sqlite): close state.db connections explicitly to stop FD leak in sidebar polling (#1494)
Production WebUI on macOS launchd reproduced an HTTP-unhealthy wedge after
#1483 closed the bootstrap supervisor double-fork: process alive, port
listening, every HTTP request reset by peer before a response. The reporter
(@insecurejezza) traced it to FD exhaustion — 366 open FDs on the wedged
process, 238 of them `~/.hermes/state.db`, `state.db-wal`, and `state.db-shm`.

Root cause: four sqlite callsites use `with sqlite3.connect(...) as conn:`.
Python's sqlite3 connection context manager only commits or rolls back on
exit; it does NOT close the connection. `/api/sessions` polling calls these
on every sidebar refresh, so each poll leaked one or more open state.db FDs
until the process hit macOS's soft FD limit and new sqlite3.connect() calls
inside fresh request handlers raised before any response bytes were written.

Fix: wrap each `sqlite3.connect(...)` in `contextlib.closing(...)` so the
connection is explicitly closed on scope exit, in addition to the auto-
commit / rollback semantics that `Connection.__exit__` already provides.

Callsites patched:
- api/agent_sessions.py:read_importable_agent_session_rows
- api/agent_sessions.py:read_session_lineage_metadata
- api/models.py:get_cli_session_messages
- api/models.py:delete_cli_session

Reporter's verification (post-patch, 100-request stress loop against
/api/sessions and /api/projects):

  batch=1 fd=92 state_handles=0
  batch=2 fd=92 state_handles=0
  ...
  batch=5 fd=92 state_handles=0

Pre-patch the same loop made FD count and state.db handle count climb
monotonically.

4 regression tests in tests/test_issue1494_state_db_fd_leak.py monkeypatch
sqlite3.connect with a tracking wrapper that records .close() calls and
assert every connection opened by each of the four functions is explicitly
closed. Verified to fail (catching the original bug) when the closing()
wrap is reverted: "leaked 5 of 5 sqlite connection(s) — context-manager-
only `with sqlite3.connect()` does not close. Wrap in contextlib.closing()."

This addresses Bug #2 of the umbrella issue #1458. Bug #3 (HTTP-unhealthy
wedge in the absence of FD exhaustion) remains open pending separate
diagnostic data — explicit scope discipline.

Closes #1494
Refs #1458 (Bug #2 of 3)

Co-authored-by: insecurejezza <70424851+insecurejezza@users.noreply.github.com>
2026-05-03 01:15:26 +00:00
Dennis Soong cbb251b823 fix: add sidebar cancel for running sessions 2026-05-03 08:46:36 +08:00
bergeouss 24a5457471 fix: P0 bugfixes — tool-card args, sw.js path, CLI rename, scroll pinning
- #1481: Use absolute path for service worker registration to avoid
  <base> tag resolution on session pages causing JSON 404
- #1484: Fix tool-card expanded args readability — replace
  word-break:break-all with pre-wrap+break-word, add display:block
  so newlines and indentation are preserved
- #1486: Prefer WebUI JSON title over state.db title for CLI sessions,
  fixing rename-not-persisting after compression chain extension
- #1469/#1360: Add _programmaticScroll guard to distinguish
  programmatic scrolls from user scrolls, preventing the race
  condition where scrollIfPinned() re-pins after user scrolls up
2026-05-02 23:39:52 +00:00
nesquena-hermes 7fddc331ae Merge pull request #1490 from nesquena/stage-271
v0.50.271 — Composer voice buttons UX (#1488)
v0.50.271
2026-05-02 15:37:22 -07:00
Hermes Bot 63361ddb1c chore(release): stamp v0.50.271 — composer voice buttons UX (#1488) 2026-05-02 22:35:07 +00:00
Hermes Bot 6b68f14884 Stage 271: PR #1489 — composer voice buttons (icon + tooltips + opt-in pref) (#1488) 2026-05-02 22:26:18 +00:00
Hermes Bot 341b1ee6b6 fix(composer): distinct voice-mode icon, descriptive labels, opt-in pref (#1488)
Composer footer rendered two near-identical mic icons whose tooltips both
said "Voice input" — push-to-talk dictation and hands-free voice mode were
visually indistinguishable. Researched how ChatGPT/Claude/Gemini solve the
same problem and adopt the industry convention.

Changes:
- btnVoiceMode now uses Lucide audio-lines (6 vertical bars), the
  universal voice-conversation glyph. Also registered in LI_PATHS.
- Distinct localized tooltips: voice_dictate ("Dictate") and
  voice_mode_toggle ("Voice mode"), with active-state flips
  (voice_dictate_active "Stop dictation", voice_mode_toggle_active
  "Exit voice mode"). Legacy voice_toggle key removed (it resolved to
  "Voice input" in every locale and caused the duplicate-tooltip bug).
- Voice mode is opt-in via Settings -> Preferences ->
  "Hands-free voice mode button" (default off). Dictation mic stays
  visible by default, unchanged. localStorage-backed; panels.js onchange
  calls window._applyVoiceModePref() so the button appears/disappears
  immediately without reload.
- 17 regression tests pin: distinct titles, audio-lines glyph, all 4
  new keys in all 9 locales, removal of stale voice_toggle, English
  labels match convention, pref gating (no unconditional display=''
  left in boot.js), Settings checkbox + i18n, panels.js wiring,
  active-state tooltip flips.

Browser-verified on port 8789: default state shows 1 mic; enabling
the pref makes the audio-waveform button appear live; tooltips read
"Dictate" and "Voice mode" distinctly.

Closes #1488
2026-05-02 22:16:23 +00:00
nesquena-hermes 913c93ae85 Merge pull request #1487 from nesquena/stage-270
v0.50.270 — Bootstrap launcher import validation (#1315) + Opus follow-up
v0.50.270
2026-05-02 12:56:25 -07:00
Hermes Bot dc36d7c977 chore(release): stamp v0.50.270 — bootstrap launcher import validation (#1315)
- CHANGELOG.md: v0.50.270 entry detailing #1315 + maintainer follow-ups
- ROADMAP.md: bump to v0.50.270, 3849 tests collected
- TESTING.md: bump header + total to 3849
- bootstrap.py: Opus advisor optional-followup — PYTHONPATH prepend comment

#1315 by @ccqqlo (113 LOC): bootstrap.py validates launcher Python can
import both yaml and run_agent.AIAgent. Companion fix to v0.50.269's #1478
— addresses the start-healthy-then-cryptic-fail mode (different from #1478's
supervisor-respawn loop).

3849 tests pass. Opus advisor verdict: ship as-is. CI green on contributor
branch + on local stage. QA harness all green.
2026-05-02 19:54:21 +00:00
Hermes Bot 58571c9221 fix(bootstrap): validate WebUI launcher can import agent (#1315) 2026-05-02 19:47:22 +00:00
Hermes Bot 9049d4d6b3 test(bootstrap): skip venv.EnvBuilder.create() in fail-loud test
The test_ensure_python_fails_loudly_when_no_interpreter_can_import_agent
test was passing locally but failing on CI runners because:

1. CI runners don't have REPO_ROOT/.venv/bin/python on the filesystem
2. The function path on missing venv calls venv.EnvBuilder(with_pip=True).create()
3. That internally calls subprocess.check_output() — a different code path
   than the monkey-patched bootstrap.subprocess.run, which only stubs run().
4. CI fails with: AttributeError: NoneType has no attribute stdout

The behavior under test is "what happens when no interpreter can import
both WebUI deps and the agent" — NOT the venv-creation path. So we sidestep
EnvBuilder by setting REPO_ROOT to tmp_path with a pre-existing
.venv/bin/python file. The venv-existence check passes, EnvBuilder is
skipped, the stubbed _python_can_run_webui_and_agent returns False on the
final check, and the expected RuntimeError fires.

Co-authored-by: ccqqlo <ccqqlo@users.noreply.github.com>
2026-05-02 19:45:54 +00:00
Hermes Bot 0076f3d9ab test(bootstrap): widen ensure_python_has_webui_deps stub for rebase onto v0.50.269
The PR added an `agent_dir` parameter to ensure_python_has_webui_deps. The
test_bootstrap_foreground.py tests (added in #1478) had `lambda p: p` stubs
that were 1-arg only. Widened to `lambda *a, **kw: a[0]` so the stubs
accept the new signature on the rebased base.

Co-authored-by: ccqqlo <ccqqlo@users.noreply.github.com>
2026-05-02 19:35:42 +00:00
milo 634f90a807 fix: validate WebUI launcher can import agent 2026-05-02 19:32:21 +00:00
nesquena-hermes b8a346f421 Merge pull request #1483 from nesquena/stage-269
v0.50.269 — Bootstrap supervisor fix (#1478) + #1473 follow-ups (#1479, #1480)
v0.50.269
2026-05-02 11:14:23 -07:00
Hermes Bot e1708c4535 chore(release): stamp v0.50.269 — bootstrap supervisor fix + 2 v0.50.267 follow-ups
- CHANGELOG.md: v0.50.269 entry detailing #1478 #1479 #1480
- ROADMAP.md: bump to v0.50.269, 3847 tests collected
- TESTING.md: bump header + total to 3847

#1478: nesquena APPROVED self-built bootstrap.py --foreground mode
       (closes #1458 Bug #1, +Opus follow-ups: XPC noise filter, executability guard)
#1479: surgical follow-up to #1473 — Session.compact() now includes pending_user_message
#1480: bfcache pageshow restores active session via loadSession + checkInflightOnBoot

3847 tests pass (+47 net). Opus advisor on stage diff: no blockers.
2026-05-02 18:12:13 +00:00
Hermes Bot 715a80569d fix(bootstrap): --foreground mode for process supervisors (#1478) 2026-05-02 18:04:44 +00:00
Hermes Bot 6aa2190cc6 fix(boot): restore inflight session on bfcache pageshow (#1480) 2026-05-02 18:04:44 +00:00
Hermes Bot 26b332612d fix(api): add pending_user_message to Session.compact() (#1479) 2026-05-02 18:04:44 +00:00
nesquena-hermes 7d5c9bd76f Merge pull request #1482 from nesquena/stage-268
v0.50.268 — 4 contributor PRs (sessions URL sync, sidebar nesting, /api/session/duplicate, Android PWA) + Opus follow-ups
v0.50.268
2026-05-02 10:57:08 -07:00
Hermes Bot bcfd8b2eac chore(release): stamp v0.50.268 — 4-PR batch + Opus follow-ups (i18n + per-session fields + None title guard)
- CHANGELOG.md: v0.50.268 entry detailing #1395 #1450 #1462 #1476 + Opus SHOULD-FIX followups
- ROADMAP.md: bump to v0.50.268, 3800 tests collected
- TESTING.md: bump header + total to 3800

SF-1 i18n fix:
- static/i18n.js: session_meta_children key in all 10 locale blocks (en, ja, ru, es, de, zh, zh-Hant x2, pt, ko)
- static/sessions.js: 2 callsites use t(session_meta_children, childCount)

SF-2 #1462 per-session field carry-over:
- api/routes.py: duplicate now carries personality, enabled_toolsets, context_length, threshold_tokens

SF-3 #1462 None-title guard:
- api/routes.py: (session.title or "Untitled") + " (copy)"

Tests:
- tests/test_stage268_opus_followups.py: 6 regression tests pinning SF-1 + SF-2 + SF-3
- tests/test_session_duplicate.py: 2 brittle assertions widened to accept new forms

Follow-up issue filed: #1481 (PWA /sw.js whitelist vestige, Opus SF-4)
2026-05-02 17:54:58 +00:00
Dennis Soong 5e806f6fd8 fix: restore inflight session on bfcache pageshow 2026-05-03 01:53:01 +08:00
Hermes Bot 6a26e82c22 fix(bootstrap): address Opus pre-merge review feedback (#1478)
Three changes from the pre-merge Opus review:

**MUST-FIX** — XPC_SERVICE_NAME false-positive on macOS Terminal

macOS launchd sets `XPC_SERVICE_NAME` in EVERY Terminal-spawned shell, not
just real services. Typical noise values: `"0"` (truthy in Python!) and
`"application.com.apple.Terminal.<UUID>"`. A bare `os.environ.get(name)`
existence check would auto-promote interactive `./start.sh` runs to
foreground mode on every Mac dev machine — silently breaking the most
common installation path (no /health probe, no browser open, no log file,
hanging shell).

Fix: new `_is_real_supervisor_value()` helper that filters noise. For
`XPC_SERVICE_NAME` specifically, reject `"0"` and any `"application.*"`
prefix. Real launchd plists use reverse-DNS Label form (`com.<rdns>.<svc>`)
which still triggers correctly.

7 new tests in `TestXPCServiceNameNoiseFilter`:
- 4 noise values (`0`, Terminal.app, iTerm2, VSCode) → no detection
- 3 real Label forms → correct detection
- Mixed env with XPC noise + real INVOCATION_ID → falls through to systemd

**SHOULD-FIX 1** — Test env leakage

The original `clean_env` fixture stripped supervisor-detection env vars
but not the resolved bootstrap vars (HERMES_WEBUI_HOST/PORT/AGENT_DIR)
that `main()` mutates onto `os.environ`. After
`test_foreground_exports_resolved_env_vars` ran, later tests would import
bootstrap with polluted defaults (DEFAULT_HOST="0.0.0.0" instead of
"127.0.0.1"). Existing assertions still passed (tautological vs DEFAULT_*),
but it was a footgun for future tests.

Fix: extend `clean_env` to also `delenv` the three resolved vars before
each test.

**SHOULD-FIX 2** — Pre-execv executability guard

If `discover_launcher_python` returns a path that doesn't exist or isn't
executable, `os.execv` raises OSError → wrapper catches → SystemExit(1)
→ supervisor restarts → loop forever. That's exactly the failure mode
this PR is supposed to eliminate.

Fix: `os.access(python_exe, os.X_OK)` check before execv. Converts
infinite supervisor loop into a single visible RuntimeError.

1 new test in `TestForegroundExecutabilityGuard` pinning that the guard
fires before execv when the python path is non-executable.

**Docs** — supervisor.md updates

- New section explaining the XPC_SERVICE_NAME noise filter and what
  values trigger / don't trigger detection
- New section listing supervisors that are NOT auto-detected (runit,
  daemontools, PM2, Foreman/Honcho, custom shell-script supervisors)
  with explicit recommendation to set HERMES_WEBUI_FOREGROUND=1

Verification

- 3820 tests pass (+9 from this commit's new tests vs the original PR
  push of 3811)
- Filter manually verified end-to-end with the live os.environ:
  XPC=0 → None, XPC=application.* → None, XPC=com.example.foo → triggers
- run-browser-tests.sh ALL CHECKS PASSED on the worktree

Items deferred from the Opus review

- #4 chdir target may not exist: REPO_ROOT comes from __file__.resolve()
  so it's stable; not a real concern in practice
- #6 two startup messages in foreground mode: cosmetic, useful for
  diagnostics
- #7 stricter explicit-only mode: leaves user the override of just not
  passing --foreground (current behavior)
- #8 test stub return value: trivial, can fix later if regression surface
- #9 argparse positional-after-option ordering: test reads fine

These can be follow-up issues if anyone hits them.
2026-05-02 17:52:13 +00:00