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
const ONBOARDING = { status : null , step : 0 , steps : [ 'system' , 'setup' , 'workspace' , 'password' , 'finish' ] , form : { provider : 'openrouter' , workspace : '' , model : '' , password : '' , apiKey : '' , baseUrl : '' } , active : false , probe : { status : 'idle' , error : null , detail : '' , models : null , probedKey : '' } } ;
// ── Onboarding base-URL probe (#1499) ───────────────────────────────────────
// Probes <base_url>/models so the wizard can validate the configured endpoint
// before persisting AND populate the model dropdown from the live catalog.
// Probe state lives on ONBOARDING.probe; the dropdown render and the
// nextOnboardingStep gate both consult it.
let _onboardingProbeTimer = null ;
function _onboardingProbeKey ( provider , baseUrl , apiKey ) {
return ` ${ provider || '' } | ${ ( baseUrl || '' ) . trim ( ) . replace ( /\/+$/ , '' ) } | ${ apiKey || '' } ` ;
}
function _setOnboardingProbeState ( patch ) {
ONBOARDING . probe = { ... ONBOARDING . probe , ... patch } ;
// Re-render body so probe status / model dropdown reflect new state.
_renderOnboardingBody ( ) ;
}
async function _runOnboardingProbe ( { force = false } = { } ) {
const provider = ONBOARDING . form . provider ;
const cat = _getOnboardingSetupProvider ( provider ) ;
if ( ! cat || ! cat . requires _base _url ) {
_setOnboardingProbeState ( { status : 'idle' , error : null , detail : '' , models : null , probedKey : '' } ) ;
return ONBOARDING . probe ;
}
const baseUrl = ( ONBOARDING . form . baseUrl || '' ) . trim ( ) ;
if ( ! baseUrl ) {
_setOnboardingProbeState ( { status : 'idle' , error : null , detail : '' , models : null , probedKey : '' } ) ;
return ONBOARDING . probe ;
}
const apiKey = ( ONBOARDING . form . apiKey || '' ) . trim ( ) ;
const key = _onboardingProbeKey ( provider , baseUrl , apiKey ) ;
if ( ! force && ONBOARDING . probe . probedKey === key && ONBOARDING . probe . status !== 'probing' ) {
return ONBOARDING . probe ;
}
_setOnboardingProbeState ( { status : 'probing' , error : null , detail : '' , probedKey : key } ) ;
try {
const res = await api ( '/api/onboarding/probe' , { method : 'POST' , body : JSON . stringify ( { provider , base _url : baseUrl , api _key : apiKey || undefined } ) } ) ;
if ( res && res . ok ) {
_setOnboardingProbeState ( { status : 'ok' , error : null , detail : '' , models : Array . isArray ( res . models ) ? res . models : [ ] , probedKey : key } ) ;
// If the user hasn't picked a model yet (or their pick is no longer in
// the list), default to the first probed model so Continue isn't blocked
// on an empty selection.
const stillPresent = ONBOARDING . form . model && ( res . models || [ ] ) . some ( m => m . id === ONBOARDING . form . model ) ;
if ( ! stillPresent && ( res . models || [ ] ) . length > 0 ) {
ONBOARDING . form . model = res . models [ 0 ] . id ;
_renderOnboardingBody ( ) ;
}
} else {
const err = ( res && res . error ) || 'unreachable' ;
const detail = ( res && res . detail ) || '' ;
_setOnboardingProbeState ( { status : 'error' , error : err , detail , models : null , probedKey : key } ) ;
}
} catch ( e ) {
_setOnboardingProbeState ( { status : 'error' , error : 'unreachable' , detail : ( e && e . message ) || String ( e ) , models : null , probedKey : key } ) ;
}
return ONBOARDING . probe ;
}
function _scheduleOnboardingProbe ( ) {
if ( _onboardingProbeTimer ) clearTimeout ( _onboardingProbeTimer ) ;
_onboardingProbeTimer = setTimeout ( ( ) => { _runOnboardingProbe ( ) ; } , 400 ) ;
}
function _onboardingProbeMessage ( probe ) {
if ( ! probe || probe . status === 'idle' ) return '' ;
if ( probe . status === 'probing' ) return t ( 'onboarding_probe_probing' ) || 'Testing connection…' ;
if ( probe . status === 'ok' ) {
const n = ( probe . models || [ ] ) . length ;
const tmpl = t ( 'onboarding_probe_ok' ) || 'Connected. {n} model(s) available.' ;
return tmpl . replace ( '{n}' , String ( n ) ) ;
}
// status === 'error'
const errKey = 'onboarding_probe_error_' + probe . error ;
const localized = t ( errKey ) ;
// i18n.js's `t()` returns the key itself when missing — fall back to a generic message.
const heading = ( localized && localized !== errKey ) ? localized : ( t ( 'onboarding_probe_error_generic' ) || 'Could not reach the configured base URL.' ) ;
const detail = probe . detail ? ` ( ${ probe . detail } ) ` : '' ;
return heading + detail ;
}
2026-04-12 07:11:41 +00:00
function _getOnboardingSetupProviders ( ) {
return ( ( ( ONBOARDING . status || { } ) . setup || { } ) . providers ) || [ ] ;
}
function _getOnboardingSetupProvider ( id ) {
return _getOnboardingSetupProviders ( ) . find ( p => p . id === id ) || null ;
}
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:33:41 +00:00
function _getOnboardingSetupCategories ( ) {
return ( ( ( ONBOARDING . status || { } ) . setup || { } ) . categories ) || [ ] ;
}
/** Render the provider <select> with <optgroup> per category. */
function _renderProviderSelectOptions ( selectedId ) {
const providers = _getOnboardingSetupProviders ( ) ;
const categories = _getOnboardingSetupCategories ( ) ;
const provMap = { } ;
providers . forEach ( p => { provMap [ p . id ] = p ; } ) ;
if ( ! categories . length ) {
// Fallback: flat list when no categories are available.
return providers . map ( p => ` <option value=" ${ esc ( p . id ) } "> ${ esc ( p . label ) } ${ p . quick ? ' — ' + esc ( t ( 'onboarding_quick_setup_badge' ) ) : '' } </option> ` ) . join ( '' ) ;
}
return categories . map ( cat => {
const opts = cat . providers . map ( pid => {
const p = provMap [ pid ] ;
if ( ! p ) return '' ;
return ` <option value=" ${ esc ( p . id ) } " ${ p . id === selectedId ? ' selected' : '' } > ${ esc ( p . label ) } ${ p . quick ? ' — ' + esc ( t ( 'onboarding_quick_setup_badge' ) ) : '' } </option> ` ;
} ) . join ( '' ) ;
return ` <optgroup label=" ${ esc ( t ( 'provider_category_' + cat . id ) || cat . label ) } "> ${ opts } </optgroup> ` ;
} ) . join ( '' ) ;
}
2026-04-12 07:11:41 +00:00
function _getOnboardingCurrentSetup ( ) {
return ( ( ( ONBOARDING . status || { } ) . setup || { } ) . current ) || { } ;
}
function _onboardingStepMeta ( key ) {
return ( {
system : { title : t ( 'onboarding_step_system_title' ) , desc : t ( 'onboarding_step_system_desc' ) } ,
setup : { title : t ( 'onboarding_step_setup_title' ) , desc : t ( 'onboarding_step_setup_desc' ) } ,
workspace : { title : t ( 'onboarding_step_workspace_title' ) , desc : t ( 'onboarding_step_workspace_desc' ) } ,
password : { title : t ( 'onboarding_step_password_title' ) , desc : t ( 'onboarding_step_password_desc' ) } ,
finish : { title : t ( 'onboarding_step_finish_title' ) , desc : t ( 'onboarding_step_finish_desc' ) }
} ) [ key ] ;
}
function _renderOnboardingSteps ( ) {
const wrap = $ ( 'onboardingSteps' ) ;
if ( ! wrap ) return ;
wrap . innerHTML = '' ;
ONBOARDING . steps . forEach ( ( key , idx ) => {
const meta = _onboardingStepMeta ( key ) ;
const item = document . createElement ( 'div' ) ;
item . className = 'onboarding-step' + ( idx === ONBOARDING . step ? ' active' : idx < ONBOARDING . step ? ' done' : '' ) ;
item . innerHTML = ` <div class="onboarding-step-index"> ${ idx + 1 } </div><div><div class="onboarding-step-title"> ${ meta . title } </div><div class="onboarding-step-desc"> ${ meta . desc } </div></div> ` ;
wrap . appendChild ( item ) ;
} ) ;
}
function _setOnboardingNotice ( msg , kind = 'info' ) {
const el = $ ( 'onboardingNotice' ) ;
if ( ! el ) return ;
if ( ! msg ) { el . style . display = 'none' ; el . textContent = '' ; el . className = 'onboarding-status' ; return ; }
el . style . display = 'block' ;
el . className = 'onboarding-status ' + kind ;
el . textContent = msg ;
}
function _getOnboardingWorkspaceChoices ( ) {
const items = ( ( ONBOARDING . status || { } ) . workspaces || { } ) . items || [ ] ;
return items . length ? items : [ { name : 'Home' , path : ONBOARDING . form . workspace || '' } ] ;
}
function _getOnboardingProviderModelChoices ( ) {
const provider = _getOnboardingSetupProvider ( ONBOARDING . form . provider ) ;
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
// Probe-discovered models (#1499) take precedence over the static catalog
// for providers with requires_base_url=True. The catalog ships an empty
// list for self-hosted providers (lmstudio, ollama, custom) — without the
// probe the user had nothing to pick from.
if ( provider && provider . requires _base _url && ONBOARDING . probe && ONBOARDING . probe . status === 'ok' && Array . isArray ( ONBOARDING . probe . models ) && ONBOARDING . probe . models . length ) {
return ONBOARDING . probe . models ;
}
2026-04-12 07:11:41 +00:00
return provider ? ( provider . models || [ ] ) : [ ] ;
}
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
function _renderOnboardingBaseUrlField ( showBaseUrl ) {
// Renders the base_url input PLUS the probe status banner / Test button
// when the active provider has requires_base_url=True (#1499). Returns
// the empty string when the active provider does not require a base URL,
// so the existing call sites can continue to template-interpolate this in
// place of the previous inline `<label …>` snippet.
if ( ! showBaseUrl ) return '' ;
const probe = ONBOARDING . probe || { status : 'idle' } ;
const msg = _onboardingProbeMessage ( probe ) ;
let banner = '' ;
if ( msg ) {
const cls = { ok : 'onboarding-probe-ok' , probing : 'onboarding-probe-probing' , error : 'onboarding-probe-error' } [ probe . status ] || '' ;
banner = ` <p class="onboarding-copy onboarding-probe-banner ${ cls } "> ${ esc ( msg ) } </p> ` ;
}
const testBtnLabel = t ( 'onboarding_probe_test_button' ) || 'Test connection' ;
const testBtnDisabled = ( probe . status === 'probing' ) ? 'disabled' : '' ;
return ` <label class="onboarding-field"><span> ${ t ( 'onboarding_base_url_label' ) } </span><input id="onboardingBaseUrlInput" value=" ${ esc ( ONBOARDING . form . baseUrl || '' ) } " placeholder=" ${ t ( 'onboarding_base_url_placeholder' ) } " oninput="ONBOARDING.form.baseUrl=this.value;_scheduleOnboardingProbe()" onblur="_runOnboardingProbe()"></label><div class="onboarding-probe-row"><button type="button" class="onboarding-probe-btn" ${ testBtnDisabled } onclick="_runOnboardingProbe({force:true})"> ${ esc ( testBtnLabel ) } </button></div> ${ banner } ` ;
}
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
function _renderOnboardingApiKeyField ( ) {
// Renders the API-key input. For providers flagged `key_optional` in the
// setup catalog (lmstudio, ollama, custom — typically self-hosted servers
// that run keyless by default), the field shows an "(optional)" hint and
// empty input is accepted on Continue. Pre-#1499-third-sub-bug-fix the
// wizard required a non-empty string here even for keyless installs, which
// forced users to type random gibberish to clear onboarding.
const provider = _getOnboardingSetupProvider ( ONBOARDING . form . provider ) ;
const keyOptional = ! ! ( provider && provider . key _optional ) ;
const labelKey = keyOptional ? 'onboarding_api_key_label_optional' : 'onboarding_api_key_label' ;
const placeholderKey = keyOptional ? 'onboarding_api_key_placeholder_optional' : 'onboarding_api_key_placeholder' ;
const helpHtml = keyOptional ? ` <p class="onboarding-copy onboarding-api-key-help"> ${ esc ( t ( 'onboarding_api_key_help_keyless' ) || '' ) } </p> ` : '' ;
2026-05-03 07:05:40 +00:00
return ` <label class="onboarding-field" id="onboardingApiKeyField"><span> ${ t ( labelKey ) } </span><input id="onboardingApiKeyInput" type="password" value=" ${ esc ( ONBOARDING . form . apiKey || '' ) } " placeholder=" ${ t ( placeholderKey ) } " oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label> ${ helpHtml } ` ;
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
}
2026-04-12 07:11:41 +00:00
function _getOnboardingSelectedModel ( ) {
return ONBOARDING . form . model || '' ;
}
function _renderOnboardingModelField ( ) {
const choices = _getOnboardingProviderModelChoices ( ) ;
if ( ONBOARDING . form . provider === 'custom' ) {
return ` <label class="onboarding-field"><span> ${ t ( 'onboarding_model_label' ) } </span><input id="onboardingModelInput" value=" ${ esc ( _getOnboardingSelectedModel ( ) ) } " placeholder=" ${ t ( 'onboarding_custom_model_placeholder' ) } " oninput="ONBOARDING.form.model=this.value"></label><p class="onboarding-copy"> ${ t ( 'onboarding_custom_model_help' ) } </p> ` ;
}
const options = choices . map ( m => ` <option value=" ${ esc ( m . id ) } "> ${ esc ( m . label ) } </option> ` ) . join ( '' ) ;
return ` <label class="onboarding-field"><span> ${ t ( 'onboarding_model_label' ) } </span><select id="onboardingModelSelect" onchange="ONBOARDING.form.model=this.value"> ${ options } </select></label><p class="onboarding-copy"> ${ t ( 'onboarding_workspace_help' ) } </p> ` ;
}
2026-05-06 01:10:45 +00:00
function _renderOnboardingProviderOAuthField ( provider ) {
if ( ! provider || provider . oauth _provider !== 'anthropic' ) return '' ;
return ` <div class="onboarding-oauth-card onboarding-oauth-pending" style="margin-top:12px">
< div class = "onboarding-oauth-icon" > 🔑 < / d i v >
< div style = "flex:1" >
< strong > Use Claude Code OAuth instead < / s t r o n g >
2026-05-06 01:29:10 +00:00
< p style = "margin-top:6px;color:var(--muted);font-size:13px" > < strong > Claude Code subscription credentials are not the same as an Anthropic API key . < / s t r o n g > U s e t h i s p a t h o n l y w h e n y o u w a n t H e r m e s t o u s e C l a u d e C o d e c r e d e n t i a l s a l r e a d y a v a i l a b l e o n t h e s e r v e r , o r s t a r t a s h o r t p o l l i n g f l o w w h i l e y o u c o m p l e t e < c o d e > c l a u d e s e t u p - t o k e n < / c o d e > o n t h e h o s t . < / p >
2026-05-06 01:10:45 +00:00
< div style = "margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap" > < button class = "sm-btn" id = "anthropicOAuthBtn" onclick = "startAnthropicOAuth()" type = "button" > Login with Claude Code < / b u t t o n > < / d i v >
< div id = "anthropicOAuthFlow" style = "display:none;margin-top:12px" > < / d i v >
< / d i v >
< / d i v > ` ;
}
2026-04-12 07:11:41 +00:00
function _providerStatusLabel ( system ) {
if ( system . chat _ready ) return t ( 'onboarding_check_provider_ready' ) ;
if ( system . provider _configured ) return t ( 'onboarding_check_provider_partial' ) ;
return t ( 'onboarding_check_provider_pending' ) ;
}
function _renderOnboardingBody ( ) {
const body = $ ( 'onboardingBody' ) ;
if ( ! body || ! ONBOARDING . status ) return ;
const key = ONBOARDING . steps [ ONBOARDING . step ] ;
const system = ONBOARDING . status . system || { } ;
const settings = ONBOARDING . status . settings || { } ;
const setup = ONBOARDING . status . setup || { } ;
const nextBtn = $ ( 'onboardingNextBtn' ) ;
const backBtn = $ ( 'onboardingBackBtn' ) ;
if ( backBtn ) backBtn . style . display = ONBOARDING . step > 0 ? '' : 'none' ;
if ( nextBtn ) nextBtn . textContent = key === 'finish' ? t ( 'onboarding_open' ) : t ( 'onboarding_continue' ) ;
if ( key === 'system' ) {
const hermesOk = system . hermes _found && system . imports _ok ;
const setupOk = ! ! system . chat _ready ;
_setOnboardingNotice ( system . provider _note || ( setupOk ? t ( 'onboarding_notice_system_ready' ) : t ( 'onboarding_notice_system_unavailable' ) ) , setupOk ? 'success' : ( hermesOk ? 'info' : 'warn' ) ) ;
body . innerHTML = `
< div class = "onboarding-panel-grid" >
< div class = "onboarding-check ${hermesOk?'ok':'warn'}" > < strong > $ { t ( 'onboarding_check_agent' ) } < / s t r o n g > < s p a n > $ { h e r m e s O k ? t ( ' o n b o a r d i n g _ c h e c k _ a g e n t _ r e a d y ' ) : t ( ' o n b o a r d i n g _ c h e c k _ a g e n t _ m i s s i n g ' ) } < / s p a n > < / d i v >
< div class = "onboarding-check ${(setupOk?'ok':system.provider_configured?'warn':'muted')}" > < strong > $ { t ( 'onboarding_check_provider' ) } < / s t r o n g > < s p a n > $ { _ p r o v i d e r S t a t u s L a b e l ( s y s t e m ) } < / s p a n > < / d i v >
< div class = "onboarding-check ${(settings.password_enabled?'ok':'muted')}" > < strong > $ { t ( 'onboarding_check_password' ) } < / s t r o n g > < s p a n > $ { s e t t i n g s . p a s s w o r d _ e n a b l e d ? t ( ' o n b o a r d i n g _ c h e c k _ p a s s w o r d _ e n a b l e d ' ) : t ( ' o n b o a r d i n g _ c h e c k _ p a s s w o r d _ d i s a b l e d ' ) } < / s p a n > < / d i v >
< / d i v >
< div class = "onboarding-copy" >
< p > < strong > $ { t ( 'onboarding_config_file' ) } < / s t r o n g > $ { e s c ( s y s t e m . c o n f i g _ p a t h | | t ( ' o n b o a r d i n g _ u n k n o w n ' ) ) } < / p >
< p > < strong > $ { t ( 'onboarding_env_file' ) } < / s t r o n g > $ { e s c ( s y s t e m . e n v _ p a t h | | t ( ' o n b o a r d i n g _ u n k n o w n ' ) ) } < / p >
< p > $ { esc ( system . provider _note || '' ) } < / p >
$ { system . current _provider ? ` <p><strong> ${ t ( 'onboarding_current_provider' ) } </strong> ${ esc ( system . current _provider ) } ${ system . current _model ? ` — ${ esc ( system . current _model ) } ` : '' } </p> ` : '' }
$ { system . current _base _url ? ` <p><strong> ${ t ( 'onboarding_base_url_label' ) } </strong> ${ esc ( system . current _base _url ) } </p> ` : '' }
$ { system . missing _modules && system . missing _modules . length ? ` <p><strong> ${ t ( 'onboarding_missing_imports' ) } </strong> ${ esc ( system . missing _modules . join ( ', ' ) ) } </p> ` : '' }
< / d i v > ` ;
return ;
}
if ( key === 'setup' ) {
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:33:41 +00:00
const selectedId = ONBOARDING . form . provider ;
const groupedOptions = _renderProviderSelectOptions ( selectedId ) ;
const provider = _getOnboardingSetupProvider ( selectedId ) || _getOnboardingSetupProviders ( ) [ 0 ] || null ;
2026-04-12 07:11:41 +00:00
const showBaseUrl = provider && provider . requires _base _url ;
2026-05-06 01:29:10 +00:00
const keyHelp = provider
? ( provider . id === 'anthropic'
? 'Anthropic API key path: paste an Anthropic Console API key here. This is separate from a Claude Code subscription; use the Claude Code OAuth card if you want subscription credentials instead.'
: ` ${ t ( 'onboarding_api_key_help_prefix' ) } ${ esc ( provider . env _var ) } . ` )
: '' ;
2026-04-12 21:28:16 +00:00
// OAuth provider path: configured via CLI, no API key input needed.
const currentIsOauth = ! ! ( ONBOARDING . status . setup || { } ) . current _is _oauth ;
const currentProviderName = ( ( ONBOARDING . status . setup || { } ) . current || { } ) . provider || '' ;
if ( currentIsOauth ) {
const isReady = ! ! ( ONBOARDING . status . system || { } ) . chat _ready ;
const providerLabel = esc ( currentProviderName ) ;
2026-05-04 21:07:16 +00:00
const codexOauthPendingBody = currentProviderName === 'openai-codex'
? 'This instance is configured to use <strong>openai-codex</strong>, which uses OAuth rather than an API key. Use the button below to authenticate with ChatGPT, then continue once provider status refreshes.'
: t ( 'onboarding_oauth_provider_not_ready_body' ) . replace ( '{provider}' , providerLabel ) ;
2026-04-12 21:28:16 +00:00
if ( isReady ) {
_setOnboardingNotice ( t ( 'onboarding_notice_setup_already_ready' ) , 'success' ) ;
body . innerHTML = `
< div class = "onboarding-oauth-card onboarding-oauth-ready" >
< div class = "onboarding-oauth-icon" > ✓ < / d i v >
< div >
< strong > $ { t ( 'onboarding_oauth_provider_ready_title' ) } < / s t r o n g >
< p > $ { t ( 'onboarding_oauth_provider_ready_body' ) . replace ( '{provider}' , providerLabel ) } < / p >
< / d i v >
< / d i v >
< p class = "onboarding-copy" style = "margin-top:20px" > $ { t ( 'onboarding_oauth_switch_hint' ) } < / p >
< label class = "onboarding-field" >
< span > $ { t ( 'onboarding_provider_label' ) } < / s p a n >
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:33:41 +00:00
< select id = "onboardingProviderSelect" onchange = "syncOnboardingProvider(this.value)" > $ { groupedOptions } < / s e l e c t >
2026-04-12 21:28:16 +00:00
< / l a b e l >
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
$ { _renderOnboardingApiKeyField ( ) }
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
$ { _renderOnboardingBaseUrlField ( showBaseUrl ) }
2026-04-12 21:28:16 +00:00
< p class = "onboarding-copy" > $ { keyHelp } < / p > ` ;
} else {
_setOnboardingNotice ( t ( 'onboarding_notice_setup_required' ) , 'warn' ) ;
body . innerHTML = `
< div class = "onboarding-oauth-card onboarding-oauth-pending" >
< div class = "onboarding-oauth-icon" > ⚠ < / d i v >
2026-05-04 21:07:16 +00:00
< div style = "flex:1" >
2026-04-12 21:28:16 +00:00
< strong > $ { t ( 'onboarding_oauth_provider_not_ready_title' ) } < / s t r o n g >
2026-05-04 21:07:16 +00:00
< p > $ { codexOauthPendingBody } < / p >
$ { currentProviderName === 'openai-codex' ? ` <div style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" type="button"> ${ t ( 'oauth_login_codex' ) } </button></div><div id="codexOAuthFlow" style="display:none;margin-top:12px"></div> ` : '' }
2026-04-12 21:28:16 +00:00
< / d i v >
< / d i v >
< p class = "onboarding-copy" style = "margin-top:20px" > $ { t ( 'onboarding_oauth_switch_hint' ) } < / p >
< label class = "onboarding-field" >
< span > $ { t ( 'onboarding_provider_label' ) } < / s p a n >
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:33:41 +00:00
< select id = "onboardingProviderSelect" onchange = "syncOnboardingProvider(this.value)" > $ { groupedOptions } < / s e l e c t >
2026-04-12 21:28:16 +00:00
< / l a b e l >
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
$ { _renderOnboardingApiKeyField ( ) }
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
$ { _renderOnboardingBaseUrlField ( showBaseUrl ) }
2026-04-12 21:28:16 +00:00
< p class = "onboarding-copy" > $ { keyHelp } < / p > ` ;
}
return ;
}
2026-04-12 07:11:41 +00:00
_setOnboardingNotice ( system . chat _ready ? t ( 'onboarding_notice_setup_already_ready' ) : t ( 'onboarding_notice_setup_required' ) , system . chat _ready ? 'success' : 'info' ) ;
body . innerHTML = `
< label class = "onboarding-field" >
< span > $ { t ( 'onboarding_provider_label' ) } < / s p a n >
v0.50.209: check-for-updates, workspace toggle, HTML preview, provider categories, queue flyout docs (#1042)
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:33:41 +00:00
< select id = "onboardingProviderSelect" onchange = "syncOnboardingProvider(this.value)" > $ { groupedOptions } < / s e l e c t >
2026-04-12 07:11:41 +00:00
< / l a b e l >
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
$ { _renderOnboardingApiKeyField ( ) }
2026-05-06 01:10:45 +00:00
$ { _renderOnboardingProviderOAuthField ( provider ) }
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
$ { _renderOnboardingBaseUrlField ( showBaseUrl ) }
2026-04-12 07:11:41 +00:00
< p class = "onboarding-copy" > $ { keyHelp } < / p >
$ { showBaseUrl ? ` <p class="onboarding-copy"> ${ t ( 'onboarding_base_url_help' ) } </p> ` : '' }
< p class = "onboarding-copy" > $ { esc ( setup . unsupported _note || '' ) || '' } < / p > ` ;
return ;
}
if ( key === 'workspace' ) {
const workspaceOptions = _getOnboardingWorkspaceChoices ( ) . map ( ws => ` <option value=" ${ esc ( ws . path ) } "> ${ esc ( ws . name || ws . path ) } — ${ esc ( ws . path ) } </option> ` ) . join ( '' ) ;
_setOnboardingNotice ( t ( 'onboarding_notice_workspace' ) , 'info' ) ;
body . innerHTML = `
< label class = "onboarding-field" >
< span > $ { t ( 'onboarding_workspace_label' ) } < / s p a n >
< select id = "onboardingWorkspaceSelect" onchange = "syncOnboardingWorkspaceSelect(this.value)" > $ { workspaceOptions } < / s e l e c t >
< / l a b e l >
< label class = "onboarding-field" >
< span > $ { t ( 'onboarding_workspace_or_path' ) } < / s p a n >
< input id = "onboardingWorkspaceInput" value = "${esc(ONBOARDING.form.workspace||'')}" placeholder = "${t('onboarding_workspace_placeholder')}" oninput = "ONBOARDING.form.workspace=this.value" >
< / l a b e l >
$ { _renderOnboardingModelField ( ) } ` ;
const wsSel = $ ( 'onboardingWorkspaceSelect' ) ;
if ( wsSel && ONBOARDING . form . workspace ) wsSel . value = ONBOARDING . form . workspace ;
const modelSel = $ ( 'onboardingModelSelect' ) ;
if ( modelSel && ONBOARDING . form . model ) modelSel . value = ONBOARDING . form . model ;
return ;
}
if ( key === 'password' ) {
_setOnboardingNotice ( settings . password _enabled ? t ( 'onboarding_notice_password_enabled' ) : t ( 'onboarding_notice_password_recommended' ) , settings . password _enabled ? 'success' : 'info' ) ;
body . innerHTML = `
< label class = "onboarding-field" >
< span > $ { t ( 'onboarding_password_label' ) } < / s p a n >
< input id = "onboardingPasswordInput" type = "password" value = "${esc(ONBOARDING.form.password||'')}" placeholder = "${t('onboarding_password_placeholder')}" oninput = "ONBOARDING.form.password=this.value" >
< / l a b e l >
< p class = "onboarding-copy" > $ { t ( 'onboarding_password_help' ) } < / p > ` ;
return ;
}
const provider = _getOnboardingSetupProvider ( ONBOARDING . form . provider ) ;
_setOnboardingNotice ( t ( 'onboarding_notice_finish' ) , 'success' ) ;
body . innerHTML = `
< div class = "onboarding-summary" >
< div > < strong > $ { t ( 'onboarding_provider_label' ) } < / s t r o n g > < s p a n > $ { e s c ( ( p r o v i d e r & & p r o v i d e r . l a b e l ) | | O N B O A R D I N G . f o r m . p r o v i d e r | | t ( ' o n b o a r d i n g _ n o t _ s e t ' ) ) } < / s p a n > < / d i v >
< div > < strong > $ { t ( 'onboarding_model_label' ) } < / s t r o n g > < s p a n > $ { e s c ( _ g e t O n b o a r d i n g S e l e c t e d M o d e l ( ) | | t ( ' o n b o a r d i n g _ n o t _ s e t ' ) ) } < / s p a n > < / d i v >
< div > < strong > $ { t ( 'onboarding_workspace_label' ) } < / s t r o n g > < s p a n > $ { e s c ( O N B O A R D I N G . f o r m . w o r k s p a c e | | t ( ' o n b o a r d i n g _ n o t _ s e t ' ) ) } < / s p a n > < / d i v >
2026-04-14 12:51:19 +00:00
< div > < strong > $ { t ( 'onboarding_check_password' ) } < / s t r o n g > < s p a n > $ { t ( _ g e t O n b o a r d i n g P a s s w o r d S u m m a r y K e y ( s e t t i n g s ) ) } < / s p a n > < / d i v >
2026-04-12 07:11:41 +00:00
< / d i v >
$ { ONBOARDING . form . baseUrl ? ` <p class="onboarding-copy"><strong> ${ t ( 'onboarding_base_url_label' ) } </strong> ${ esc ( ONBOARDING . form . baseUrl ) } </p> ` : '' }
< p class = "onboarding-copy" > $ { t ( 'onboarding_finish_help' ) } < / p > ` ;
}
2026-04-14 12:51:19 +00:00
function _getOnboardingPasswordSummaryKey ( settings ) {
const hasExistingPassword = ! ! ( settings && settings . password _enabled ) ;
const hasNewPassword = ! ! ( ( ONBOARDING . form . password || '' ) . trim ( ) ) ;
if ( hasNewPassword ) return hasExistingPassword ? 'onboarding_password_will_replace' : 'onboarding_password_will_enable' ;
return hasExistingPassword ? 'onboarding_password_keep_existing' : 'onboarding_password_remains_disabled' ;
}
2026-04-12 07:11:41 +00:00
function syncOnboardingWorkspaceSelect ( value ) {
ONBOARDING . form . workspace = value ;
const input = $ ( 'onboardingWorkspaceInput' ) ;
if ( input ) input . value = value ;
}
function syncOnboardingProvider ( value ) {
const provider = _getOnboardingSetupProvider ( value ) ;
ONBOARDING . form . provider = value ;
if ( provider ) {
if ( ! ONBOARDING . form . model || ! _getOnboardingProviderModelChoices ( ) . some ( m => m . id === ONBOARDING . form . model ) || value === 'custom' ) {
ONBOARDING . form . model = provider . default _model || '' ;
}
if ( provider . requires _base _url ) {
ONBOARDING . form . baseUrl = ONBOARDING . form . baseUrl || provider . default _base _url || '' ;
} else {
ONBOARDING . form . baseUrl = provider . default _base _url || '' ;
}
}
_renderOnboardingBody ( ) ;
}
async function loadOnboardingWizard ( ) {
try {
const status = await api ( '/api/onboarding/status' ) ;
ONBOARDING . status = status ;
const current = ( ( status . setup || { } ) . current ) || { } ;
ONBOARDING . form . provider = current . provider || 'openrouter' ;
ONBOARDING . form . workspace = ( status . workspaces && status . workspaces . last ) || status . settings . default _workspace || '' ;
v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs
Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
* fix(models): add gpt-5.5-mini to copilot provider catalog
* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)
Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.
Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.
Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.
3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.
* fix(onboarding): non-standard provider/path cluster (#1029)
* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)
The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.
Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:
syncTopbar() — restores model chip, title, topbar state
syncWorkspacePanelState() — restores workspace panel open/closed
_initResizePanels() — reattaches panel resize drag listeners
startGatewaySSE() — reconnects the gateway SSE watcher
(bfcache-persisted connections are dead)
All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.
loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.
7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.
* fix(bfcache): also close open dropdowns on bfcache restore (#1045)
Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.
Extend the pageshow handler to call all four named close functions before
the layout sync:
closeModelDropdown() — composer model selector
closeReasoningDropdown() — thinking/reasoning effort selector
closeWsDropdown() — workspace chip dropdown
closeProfileDropdown() — profile switcher dropdown
All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.
2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)
2221/2221 tests passing.
* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners
* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test
* fix(sessions): use cron job name as session title when available (#1032)
* fix(test): add id column to messages table in cron title test fixture
* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block
* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)
When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.
Fix: intercept 401 responses client-side before surfacing any error.
- workspace.js api(): check res.status===401 first; call
window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
calls that bypass api() — api/models, api/models/live, api/upload
All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.
6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.
* fix(pwa): preserve current URL in ?next= param on 401 redirect
* fix(test): update 401-redirect assertion to accept ?next= URL format
* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login
Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.
* Implement session agent cache for AIAgent reuse
Added session agent cache to reuse AIAgent across messages.
* Implement agent caching for session management
* Implement session agent eviction on session deletion
Added session agent eviction to prevent turn count leakage in recycled sessions.
* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)
* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210
Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:
- Mermaid CSP font fix (#1044) → v0.50.210 / Mermaid Google Fonts CSP
- bfcache layout restore (#1045) → v0.50.210 / bfcache layout and dropdown restore
- iOS PWA auth redirect (#1038) → v0.50.210 / Login redirects back to original URL
The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:47:44 +00:00
ONBOARDING . form . model = status . settings . default _model || current . model || '' ;
2026-04-12 07:11:41 +00:00
ONBOARDING . form . password = '' ;
ONBOARDING . form . apiKey = '' ;
ONBOARDING . form . baseUrl = current . base _url || '' ;
ONBOARDING . active = ! status . completed ;
if ( ! ONBOARDING . active ) return false ;
$ ( 'onboardingOverlay' ) . style . display = 'flex' ;
_renderOnboardingSteps ( ) ;
_renderOnboardingBody ( ) ;
return true ;
} catch ( e ) {
console . warn ( 'onboarding status failed' , e ) ;
return false ;
}
}
function prevOnboardingStep ( ) {
if ( ONBOARDING . step === 0 ) return ;
ONBOARDING . step -- ;
_renderOnboardingSteps ( ) ;
_renderOnboardingBody ( ) ;
}
async function _saveOnboardingProviderSetup ( ) {
const provider = ( ONBOARDING . form . provider || '' ) . trim ( ) ;
const model = ( ONBOARDING . form . model || '' ) . trim ( ) ;
const apiKey = ( ONBOARDING . form . apiKey || '' ) . trim ( ) ;
const baseUrl = ( ONBOARDING . form . baseUrl || '' ) . trim ( ) ;
const current = _getOnboardingCurrentSetup ( ) ;
const isUnchanged = current . provider === provider && ( ( current . model || '' ) === model ) && ( ( current . base _url || '' ) === baseUrl ) ;
2026-04-16 06:39:07 +00:00
// Skip the POST when nothing changed. We also skip when the provider is
// unsupported/OAuth-based and already working — chat_ready may be false for
// providers not in the quick-setup list (e.g. minimax-cn) even though they are
// fully configured. Posting in that case would either be a no-op (the server
// just marks complete for unsupported providers) or could silently overwrite
// config.yaml if the user accidentally changed the provider dropdown.
const currentIsOauth = ! ! ( ONBOARDING . status && ONBOARDING . status . setup && ONBOARDING . status . setup . current _is _oauth ) ;
if ( isUnchanged && ! apiKey && ( ( ONBOARDING . status . system || { } ) . chat _ready || currentIsOauth ) ) return ;
2026-04-12 07:11:41 +00:00
const body = { provider , model } ;
if ( apiKey ) body . api _key = apiKey ;
if ( baseUrl ) body . base _url = baseUrl ;
const status = await api ( '/api/onboarding/setup' , { method : 'POST' , body : JSON . stringify ( body ) } ) ;
ONBOARDING . status = status ;
}
async function _saveOnboardingDefaults ( ) {
const workspace = ( ONBOARDING . form . workspace || '' ) . trim ( ) ;
const model = ( ONBOARDING . form . model || '' ) . trim ( ) ;
const password = ( ONBOARDING . form . password || '' ) . trim ( ) ;
if ( ! workspace ) throw new Error ( t ( 'onboarding_error_choose_workspace' ) ) ;
if ( ! model ) throw new Error ( t ( 'onboarding_error_choose_model' ) ) ;
const known = _getOnboardingWorkspaceChoices ( ) . some ( ws => ws . path === workspace ) ;
if ( ! known ) {
await api ( '/api/workspaces/add' , { method : 'POST' , body : JSON . stringify ( { path : workspace } ) } ) ;
}
2026-04-20 22:12:01 +00:00
// Model persisted by /api/onboarding/setup — no /api/default-model call needed here
const body = { default _workspace : workspace } ;
2026-04-12 07:11:41 +00:00
if ( password ) body . _set _password = password ;
2026-04-14 12:51:19 +00:00
const saved = await api ( '/api/settings' , { method : 'POST' , body : JSON . stringify ( body ) } ) ;
if ( ONBOARDING . status ) {
ONBOARDING . status . settings = { ... ( ONBOARDING . status . settings || { } ) , password _enabled : ! ! saved . auth _enabled } ;
}
2026-05-05 17:16:00 +00:00
try { localStorage . setItem ( 'hermes-webui-model' , model ) } catch { }
2026-04-12 07:11:41 +00:00
if ( $ ( 'modelSelect' ) ) _applyModelToDropdown ( model , $ ( 'modelSelect' ) ) ;
}
async function _finishOnboarding ( ) {
await _saveOnboardingProviderSetup ( ) ;
await _saveOnboardingDefaults ( ) ;
const done = await api ( '/api/onboarding/complete' , { method : 'POST' , body : '{}' } ) ;
ONBOARDING . status = done ;
ONBOARDING . active = false ;
$ ( 'onboardingOverlay' ) . style . display = 'none' ;
showToast ( t ( 'onboarding_complete' ) ) ;
await loadWorkspaceList ( ) ;
if ( typeof renderSessionList === 'function' ) await renderSessionList ( ) ;
if ( ! S . session && typeof newSession === 'function' ) {
await newSession ( true ) ;
await renderSessionList ( ) ;
}
}
2026-04-14 16:45:12 +00:00
async function skipOnboarding ( ) {
try {
// Mark onboarding completed server-side without changing any config
await api ( '/api/onboarding/complete' , { method : 'POST' , body : '{}' } ) ;
ONBOARDING . active = false ;
$ ( 'onboardingOverlay' ) . style . display = 'none' ;
showToast ( t ( 'onboarding_skipped' ) || 'Setup skipped' ) ;
} catch ( e ) {
_setOnboardingNotice ( ( e . message || String ( e ) ) , 'warn' ) ;
}
}
2026-04-12 07:11:41 +00:00
async function nextOnboardingStep ( ) {
try {
if ( ONBOARDING . steps [ ONBOARDING . step ] === 'setup' ) {
ONBOARDING . form . provider = ( ( $ ( 'onboardingProviderSelect' ) || { } ) . value || ONBOARDING . form . provider || '' ) . trim ( ) ;
ONBOARDING . form . apiKey = ( ( $ ( 'onboardingApiKeyInput' ) || { } ) . value || '' ) . trim ( ) ;
ONBOARDING . form . baseUrl = ( ( $ ( 'onboardingBaseUrlInput' ) || { } ) . value || ONBOARDING . form . baseUrl || '' ) . trim ( ) ;
if ( ! ONBOARDING . form . provider ) throw new Error ( t ( 'onboarding_error_provider_required' ) ) ;
if ( ONBOARDING . form . provider === 'custom' && ! ONBOARDING . form . baseUrl ) throw new Error ( t ( 'onboarding_error_base_url_required' ) ) ;
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
// For self-hosted providers (requires_base_url=True), gate Continue on a
// successful probe of <base_url>/models — otherwise the wizard would
// happily persist an unreachable URL and finish in 200ms with no
// outbound HTTP, exactly the bug in #1499. Run the probe synchronously
// here, then check status; the probe is idempotent & cached on
// (provider, baseUrl, apiKey) so this rarely triggers a second network
// call when the user already saw a green banner.
const cat = _getOnboardingSetupProvider ( ONBOARDING . form . provider ) ;
if ( cat && cat . requires _base _url ) {
if ( ! ONBOARDING . form . baseUrl ) throw new Error ( t ( 'onboarding_error_base_url_required' ) ) ;
await _runOnboardingProbe ( ) ;
if ( ONBOARDING . probe . status !== 'ok' ) {
// Surface the same localized error string the inline banner shows.
const msg = _onboardingProbeMessage ( ONBOARDING . probe ) || t ( 'onboarding_error_probe_failed' ) || 'Could not reach the configured base URL.' ;
throw new Error ( msg ) ;
}
}
2026-04-12 07:11:41 +00:00
}
if ( ONBOARDING . steps [ ONBOARDING . step ] === 'workspace' ) {
ONBOARDING . form . workspace = ( ( $ ( 'onboardingWorkspaceInput' ) || { } ) . value || ONBOARDING . form . workspace || '' ) . trim ( ) ;
ONBOARDING . form . model = ( ( $ ( 'onboardingModelInput' ) || { } ) . value || ( $ ( 'onboardingModelSelect' ) || { } ) . value || ONBOARDING . form . model || '' ) . trim ( ) ;
if ( ! ONBOARDING . form . workspace ) throw new Error ( t ( 'onboarding_error_workspace_required' ) ) ;
if ( ! ONBOARDING . form . model ) throw new Error ( t ( 'onboarding_error_model_required' ) ) ;
}
if ( ONBOARDING . steps [ ONBOARDING . step ] === 'password' ) {
ONBOARDING . form . password = ( ( $ ( 'onboardingPasswordInput' ) || { } ) . value || '' ) . trim ( ) ;
}
if ( ONBOARDING . step === ONBOARDING . steps . length - 1 ) {
await _finishOnboarding ( ) ;
return ;
}
ONBOARDING . step ++ ;
_renderOnboardingSteps ( ) ;
_renderOnboardingBody ( ) ;
} catch ( e ) {
_setOnboardingNotice ( e . message || String ( e ) , 'warn' ) ;
}
}
2026-05-01 12:42:21 +00:00
/* ── Codex OAuth device-code flow ── */
2026-05-04 21:07:16 +00:00
let _codexOAuthPollTimer = null ;
let _codexOAuthFlowId = null ;
function _clearCodexOAuthPoll ( ) {
if ( _codexOAuthPollTimer ) { clearTimeout ( _codexOAuthPollTimer ) ; _codexOAuthPollTimer = null ; }
}
function _setCodexOAuthButton ( enabled ) {
const btn = $ ( 'codexOAuthBtn' ) ;
if ( btn ) { btn . disabled = ! enabled ; btn . textContent = enabled ? t ( 'oauth_login_codex' ) : '...' ; }
}
async function copyCodexOAuthCode ( code ) {
try {
await navigator . clipboard . writeText ( code || '' ) ;
showToast ( 'Code copied' ) ;
} catch ( e ) {
showToast ( code || '' ) ;
}
}
async function cancelCodexOAuth ( ) {
const flowDiv = $ ( 'codexOAuthFlow' ) ;
const flowId = _codexOAuthFlowId ;
_clearCodexOAuthPoll ( ) ;
_codexOAuthFlowId = null ;
if ( flowId ) {
try { await api ( '/api/onboarding/oauth/cancel' , { method : 'POST' , body : JSON . stringify ( { flow _id : flowId } ) } ) ; } catch ( e ) { }
}
_setCodexOAuthButton ( true ) ;
if ( flowDiv ) {
flowDiv . innerHTML = ` <div class="onboarding-oauth-card"><div class="onboarding-oauth-icon">⏹</div><div><strong>OAuth login cancelled</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">Start again whenever you're ready.</p></div></div> ` ;
}
}
function _renderCodexOAuthTerminal ( status , message ) {
const flowDiv = $ ( 'codexOAuthFlow' ) ;
if ( ! flowDiv ) return ;
const ok = status === 'success' ;
const icon = ok ? '✅' : status === 'expired' ? '⌛' : status === 'cancelled' ? '⏹' : '❌' ;
const title = ok ? t ( 'oauth_codex_success' ) : ( status === 'expired' ? t ( 'oauth_codex_expired' ) : ( status === 'cancelled' ? 'OAuth login cancelled' : t ( 'oauth_codex_error' ) ) ) ;
flowDiv . innerHTML = `
< div class = "onboarding-oauth-card ${ok?'onboarding-oauth-ready':''}" $ { ok ? '' : 'style="border-color:var(--error,#e55)"' } >
< div class = "onboarding-oauth-icon" > $ { icon } < / d i v >
< div > < strong > $ { title } < / s t r o n g > < p s t y l e = " m a r g i n - t o p : 6 p x ; c o l o r : v a r ( - - m u t e d ) ; f o n t - s i z e : 1 3 p x " > $ { e s c ( m e s s a g e | | ' ' ) } < / p > < / d i v >
< / d i v > ` ;
}
async function _pollCodexOAuth ( ) {
const flowId = _codexOAuthFlowId ;
if ( ! flowId ) return ;
try {
const resp = await api ( '/api/onboarding/oauth/poll?flow_id=' + encodeURIComponent ( flowId ) ) ;
const status = ( resp && resp . status ) || 'error' ;
if ( status === 'pending' ) {
_codexOAuthPollTimer = setTimeout ( _pollCodexOAuth , 3000 ) ;
return ;
}
_clearCodexOAuthPoll ( ) ;
_codexOAuthFlowId = null ;
_setCodexOAuthButton ( true ) ;
if ( status === 'success' ) {
_renderCodexOAuthTerminal ( 'success' , 'Credentials saved to the Hermes credential pool. Refreshing provider status…' ) ;
showToast ( t ( 'oauth_codex_success' ) ) ;
try { await loadOnboardingWizard ( ) ; } catch ( e ) { }
} else if ( status === 'expired' ) {
_renderCodexOAuthTerminal ( 'expired' , 'The code expired. Start a new login flow to try again.' ) ;
} else if ( status === 'cancelled' ) {
_renderCodexOAuthTerminal ( 'cancelled' , 'The login flow was cancelled.' ) ;
} else {
_renderCodexOAuthTerminal ( 'error' , ( resp && resp . error ) || 'OAuth login failed. Please try again.' ) ;
}
} catch ( e ) {
_clearCodexOAuthPoll ( ) ;
_codexOAuthFlowId = null ;
_setCodexOAuthButton ( true ) ;
_renderCodexOAuthTerminal ( 'error' , ( e && e . message ) || String ( e ) ) ;
}
}
2026-05-01 12:42:21 +00:00
async function startCodexOAuth ( ) {
const flowDiv = $ ( 'codexOAuthFlow' ) ;
if ( ! flowDiv ) return ;
2026-05-04 21:07:16 +00:00
_clearCodexOAuthPoll ( ) ;
_codexOAuthFlowId = null ;
_setCodexOAuthButton ( false ) ;
2026-05-01 12:42:21 +00:00
flowDiv . style . display = 'block' ;
flowDiv . innerHTML = ` <div class="onboarding-oauth-card onboarding-oauth-pending"><div class="onboarding-oauth-icon">⏳</div><div><strong> ${ t ( 'oauth_codex_polling' ) } </strong><p>Starting device-code flow…</p></div></div> ` ;
try {
2026-05-04 21:07:16 +00:00
const resp = await api ( '/api/onboarding/oauth/start' , { method : 'POST' , body : JSON . stringify ( { provider : 'openai-codex' } ) } ) ;
2026-05-01 12:42:21 +00:00
if ( resp . error ) throw new Error ( resp . error ) ;
2026-05-04 21:07:16 +00:00
const { flow _id , user _code , verification _uri } = resp ;
if ( ! flow _id || ! user _code || ! verification _uri ) throw new Error ( 'Invalid OAuth response' ) ;
_codexOAuthFlowId = flow _id ;
2026-05-01 12:42:21 +00:00
flowDiv . innerHTML = `
< div class = "onboarding-oauth-card onboarding-oauth-pending" >
< div class = "onboarding-oauth-icon" > 📋 < / d i v >
< div style = "flex:1" >
< strong > $ { t ( 'oauth_codex_step1' ) } < / s t r o n g >
< p > < a href = "${esc(verification_uri)}" target = "_blank" rel = "noopener" style = "color:var(--accent);word-break:break-all" > $ { esc ( verification _uri ) } < / a > < / p >
< p style = "margin-top:8px" > < strong > $ { t ( 'oauth_codex_step2' ) } < / s t r o n g > < / p >
2026-05-04 21:07:16 +00:00
< div style = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px" >
< code style = "display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;user-select:all" > $ { esc ( user _code ) } < / c o d e >
< button class = "sm-btn" type = "button" onclick = "copyCodexOAuthCode('${esc(user_code)}')" > Copy code < / b u t t o n >
< button class = "sm-btn" type = "button" onclick = "cancelCodexOAuth()" > Cancel < / b u t t o n >
< / d i v >
2026-05-01 12:42:21 +00:00
< p style = "margin-top:8px;color:var(--muted);font-size:13px" > $ { t ( 'oauth_codex_polling' ) } < / p >
< / d i v >
< / d i v > ` ;
2026-05-04 21:07:16 +00:00
_codexOAuthPollTimer = setTimeout ( _pollCodexOAuth , Math . max ( 1000 , Number ( resp . poll _interval _seconds || 3 ) * 1000 ) ) ;
2026-05-01 12:42:21 +00:00
} catch ( e ) {
2026-05-04 21:07:16 +00:00
_clearCodexOAuthPoll ( ) ;
_codexOAuthFlowId = null ;
_renderCodexOAuthTerminal ( 'error' , ( e && e . message ) || String ( e ) ) ;
_setCodexOAuthButton ( true ) ;
2026-05-01 12:42:21 +00:00
}
}
2026-05-06 01:10:45 +00:00
/* ── Anthropic / Claude Code credential-link flow ── */
let _anthropicOAuthPollTimer = null ;
let _anthropicOAuthFlowId = null ;
function _clearAnthropicOAuthPoll ( ) {
if ( _anthropicOAuthPollTimer ) { clearTimeout ( _anthropicOAuthPollTimer ) ; _anthropicOAuthPollTimer = null ; }
}
function _setAnthropicOAuthButton ( enabled ) {
const btn = $ ( 'anthropicOAuthBtn' ) ;
if ( btn ) { btn . disabled = ! enabled ; btn . textContent = enabled ? 'Login with Claude Code' : '...' ; }
}
async function cancelAnthropicOAuth ( ) {
const flowDiv = $ ( 'anthropicOAuthFlow' ) ;
const flowId = _anthropicOAuthFlowId ;
_clearAnthropicOAuthPoll ( ) ;
_anthropicOAuthFlowId = null ;
if ( flowId ) {
try { await api ( '/api/onboarding/oauth/cancel' , { method : 'POST' , body : JSON . stringify ( { flow _id : flowId , provider : 'anthropic' } ) } ) ; } catch ( e ) { }
}
_setAnthropicOAuthButton ( true ) ;
if ( flowDiv ) {
flowDiv . innerHTML = ` <div class="onboarding-oauth-card"><div class="onboarding-oauth-icon">⏹</div><div><strong>Claude Code OAuth cancelled</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">Start again whenever you're ready.</p></div></div> ` ;
}
}
function _renderAnthropicOAuthTerminal ( status , message ) {
const flowDiv = $ ( 'anthropicOAuthFlow' ) ;
if ( ! flowDiv ) return ;
const ok = status === 'success' ;
const icon = ok ? '✅' : status === 'expired' ? '⌛' : status === 'cancelled' ? '⏹' : '❌' ;
const title = ok ? 'Claude Code OAuth linked' : ( status === 'expired' ? 'Claude Code polling expired' : ( status === 'cancelled' ? 'Claude Code OAuth cancelled' : 'Claude Code OAuth failed' ) ) ;
flowDiv . style . display = 'block' ;
flowDiv . innerHTML = `
< div class = "onboarding-oauth-card ${ok?'onboarding-oauth-ready':''}" $ { ok ? '' : 'style="border-color:var(--error,#e55)"' } >
< div class = "onboarding-oauth-icon" > $ { icon } < / d i v >
< div > < strong > $ { title } < / s t r o n g > < p s t y l e = " m a r g i n - t o p : 6 p x ; c o l o r : v a r ( - - m u t e d ) ; f o n t - s i z e : 1 3 p x " > $ { e s c ( m e s s a g e | | ' ' ) } < / p > < / d i v >
< / d i v > ` ;
}
async function _pollAnthropicOAuth ( ) {
const flowId = _anthropicOAuthFlowId ;
if ( ! flowId ) return ;
try {
const resp = await api ( '/api/onboarding/oauth/poll?flow_id=' + encodeURIComponent ( flowId ) ) ;
const status = ( resp && resp . status ) || 'error' ;
if ( status === 'pending' ) {
_anthropicOAuthPollTimer = setTimeout ( _pollAnthropicOAuth , 3000 ) ;
return ;
}
_clearAnthropicOAuthPoll ( ) ;
_anthropicOAuthFlowId = null ;
_setAnthropicOAuthButton ( true ) ;
if ( status === 'success' ) {
_renderAnthropicOAuthTerminal ( 'success' , 'Hermes is now linked to Claude Code credentials. Refreshing provider status…' ) ;
showToast ( 'Claude Code OAuth linked' ) ;
try { await loadOnboardingWizard ( ) ; } catch ( e ) { }
} else if ( status === 'expired' ) {
_renderAnthropicOAuthTerminal ( 'expired' , 'Claude Code credentials were not detected before this flow expired. Start a new flow to try again.' ) ;
} else if ( status === 'cancelled' ) {
_renderAnthropicOAuthTerminal ( 'cancelled' , 'The login flow was cancelled.' ) ;
} else {
_renderAnthropicOAuthTerminal ( 'error' , ( resp && resp . error ) || 'Claude Code OAuth linking failed. Please try again.' ) ;
}
} catch ( e ) {
_clearAnthropicOAuthPoll ( ) ;
_anthropicOAuthFlowId = null ;
_setAnthropicOAuthButton ( true ) ;
_renderAnthropicOAuthTerminal ( 'error' , ( e && e . message ) || String ( e ) ) ;
}
}
async function startAnthropicOAuth ( ) {
const flowDiv = $ ( 'anthropicOAuthFlow' ) ;
if ( ! flowDiv ) return ;
_clearAnthropicOAuthPoll ( ) ;
_anthropicOAuthFlowId = null ;
_setAnthropicOAuthButton ( false ) ;
flowDiv . style . display = 'block' ;
flowDiv . innerHTML = ` <div class="onboarding-oauth-card onboarding-oauth-pending"><div class="onboarding-oauth-icon">⏳</div><div><strong>Checking Claude Code credentials…</strong><p>Hermes is checking for existing Claude Code OAuth credentials on this server.</p></div></div> ` ;
try {
const resp = await api ( '/api/onboarding/oauth/start' , { method : 'POST' , body : JSON . stringify ( { provider : 'anthropic' } ) } ) ;
if ( resp . error ) throw new Error ( resp . error ) ;
const { flow _id , status , action _required } = resp ;
if ( ! flow _id ) throw new Error ( 'Invalid OAuth response' ) ;
_anthropicOAuthFlowId = flow _id ;
if ( status === 'success' ) {
_clearAnthropicOAuthPoll ( ) ;
_anthropicOAuthFlowId = null ;
_setAnthropicOAuthButton ( true ) ;
_renderAnthropicOAuthTerminal ( 'success' , 'Hermes is now linked to Claude Code credentials. Refreshing provider status…' ) ;
showToast ( 'Claude Code OAuth linked' ) ;
try { await loadOnboardingWizard ( ) ; } catch ( e ) { }
return ;
}
flowDiv . innerHTML = `
< div class = "onboarding-oauth-card onboarding-oauth-pending" >
< div class = "onboarding-oauth-icon" > 🖥 ️ < / d i v >
< div style = "flex:1" >
< strong > Complete Claude Code login on this host < / s t r o n g >
< p style = "margin-top:6px" > $ { esc ( action _required || "Run 'claude setup-token' on the server, then return here. Hermes will detect the credential automatically." ) } < / p >
< div style = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px" >
< code style = "display:inline-block;background:rgba(255,255,255,.08);padding:6px 10px;border-radius:8px;user-select:all" > claude setup - token < / c o d e >
< button class = "sm-btn" type = "button" onclick = "cancelAnthropicOAuth()" > Cancel < / b u t t o n >
< / d i v >
< p style = "margin-top:8px;color:var(--muted);font-size:13px" > Waiting for Claude Code credentials ... < / p >
< / d i v >
< / d i v > ` ;
_anthropicOAuthPollTimer = setTimeout ( _pollAnthropicOAuth , Math . max ( 1000 , Number ( resp . poll _interval _seconds || 3 ) * 1000 ) ) ;
} catch ( e ) {
_clearAnthropicOAuthPoll ( ) ;
_anthropicOAuthFlowId = null ;
_renderAnthropicOAuthTerminal ( 'error' , ( e && e . message ) || String ( e ) ) ;
_setAnthropicOAuthButton ( true ) ;
}
}