__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.
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.
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>
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).
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.
- 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>
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>
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.
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)
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#1499Closes#1500
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
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>
- 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).
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>
- #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
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
- 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.
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>
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>
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.