Skip to content

v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy#1127

Merged
nesquena-hermes merged 7 commits intomasterfrom
stage
Apr 26, 2026
Merged

v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy#1127
nesquena-hermes merged 7 commits intomasterfrom
stage

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

v0.50.223 — 5 bugfix/feature PRs

Batch reviewed, fixed, and tested end-to-end on top of v0.50.222.

Included PRs

PR Title Author Absorb fixes
#1126 fix(#604): model picker shows all configured providers @bergeouss None
#1121 fix(#1118): retry api() on network errors after long idle @bergeouss Includes #1112 CSP fix (stacked)
#1122 feat(#1116): composer placeholder reflects active profile @bergeouss None
#1123 feat(#1097): drag & drop workspace files into composer @bergeouss None
#1125 fix(#1096): copy buttons + clipboard-write Permissions-Policy @bergeouss Conflict in test_issue1096_copy_buttons.py resolved (kept new comprehensive tests + preserved TestCopyFailedI18n locale regression)

Held PRs (not included)

Gate results

  • pytest: 2572 passed, 0 failed (2538 baseline + 34 new)
  • QA harness: pending (runs on approve + merge)

What ships

  • Model picker now surfaces xAI, Mistral, and any provider in config.yaml providers: section
  • api() transparently retries on stale keep-alive connections (no more "Failed to load session" after idle)
  • Google Fonts unblocked in CSP (needed for Mermaid themes)
  • Composer placeholder shows active profile name (personalised UX)
  • Workspace file tree items are draggable — drop to insert @path reference
  • Copy buttons work in Firefox (clipboard-write Permissions-Policy) + robust fallback

bergeouss and others added 7 commits April 26, 2026 22:12
Two fixes to ensure the model picker surface every provider a user has
configured:

1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
   (→ mistralai). Previously these providers were only detectable via
   hermes auth or credential pool, not via environment variables.

2. Added config.yaml providers section scanning. Users who configure
   providers in config.yaml (e.g. providers.anthropic.api_key) without
   setting the corresponding env var will now see those providers in the
   model picker. Only providers with known model catalogs are added.

- Added 12 regression tests
Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.

- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass
After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.

- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests
When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.

- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests
Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.

- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
  inserts @path with smart spacing and cursor positioning
- Added 9 regression tests
Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.

- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
  to execCommand('copy'). _fallbackCopy extracted as separate function
  with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests
Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — end-to-end ✅ (clean approve)

What this ships

v0.50.223 batch — five contributor PRs absorbed and tested end-to-end on top of v0.50.222:

PR Area Files
#1126 (#604) Model picker shows all configured providers api/config.py:1413-1455
#1121 (#1118) api() retries on stale keep-alive after idle static/workspace.js:1-37
(#1112) Google Fonts allowed in CSP api/helpers.py:46-49
#1122 (#1116) Composer placeholder reflects active profile static/boot.js:769-784, static/panels.js:1981-1985
#1123 (#1097) Drag & drop workspace files into composer static/ui.js:3035-3040, static/panels.js:2116-2143
#1125 (#1096) Copy buttons + clipboard-write Permissions-Policy api/helpers.py:54-55, static/ui.js:1528-1551

Held: #1124 (composer drafts) — flagged with detailed comment per the agent's review.

Traced against upstream hermes-agent

Pulled fresh nousresearch/hermes-agent tarball. The agent has its own _PROVIDER_MODELS constant in hermes_cli/models.py:109 that's independently maintained. The WebUI's new cfg["providers"] scan at api/config.py:1450-1455 only populates the model-picker UI's detected_providers set — it doesn't write back to config.yaml or session state, so the CLI never sees this WebUI-internal classification. ✅ no cross-tool risk.

End-to-end trace

CSP relaxation — Google Fonts (highest-stakes)

api/helpers.py:46-49:

style-src ... https://fonts.googleapis.com
font-src  ... https://fonts.gstatic.com

Security analysis:

  • Both directives are destination allowlists, not script-execution allowlists. Adding Google Fonts CDN to style-src lets the browser fetch Roboto-and-friends CSS at render time when Mermaid themes inject @import url(fonts.googleapis.com/...).
  • CSS injection from a compromised Google Fonts CDN is a real attack class (UI redress, attribute-selector exfil), but the threat model here is "Google's font CDN gets compromised AND attacker leverages CSS-only attacks" — extremely low. The font-src addition only allows fetching font binaries from fonts.gstatic.com; font files are not executable.
  • script-src is unchanged. JS execution surface is unaffected.
  • The trade-off vs. self-hosting Google Fonts is documented in the CHANGELOG and commit message.

Permissions-Policy clipboard-write

api/helpers.py:54 — appends clipboard-write=(self) to existing camera=(), microphone=(self), geolocation=(). Scope is (self) not *, so only the same origin can use navigator.clipboard.writeText(). Firefox required this explicit allow; Chrome was already permissive. ✅

cfg["providers"] scan in _build_available_models_uncached

api/config.py:1450-1455:

_cfg_providers = cfg.get("providers", {})
if isinstance(_cfg_providers, dict):
    for _pid_key in _cfg_providers:
        if _pid_key in _PROVIDER_MODELS:
            detected_providers.add(_pid_key)

Type-guard via isinstance(dict) and gate via _pid_key in _PROVIDER_MODELS — only providers with known model catalogs are added. An attacker who can inject arbitrary keys into providers: would still need that key to match a hardcoded _PROVIDER_MODELS entry to surface in the UI, which gives them nothing exploitable (they already have config write access).

Also adds env-var detection for XAI_API_KEY → x-ai and MISTRAL_API_KEY → mistralai (config.py:1416-1417, 1440-1443). Test suite verifies these map to the correct provider IDs (x-ai not xai, mistralai not mistral).

api() retry on TypeError

static/workspace.js:1-37 — wraps the existing fetch + res.ok flow in a 3-iteration for(attempt<3) loop. The catch block:

  1. Re-throws if message contains /401/ (defensive — 401 is actually a window.location.href + return so it never throws).
  2. continues on e instanceof TypeError && attempt<2 — only retries on network failures (TypeError from fetch), not HTTP errors.
  3. Throws otherwise.

Verified all 4 scenarios with a behavioural harness:

Test1 retry-on-TypeError (success on 2nd):  PASS (calls=2)
Test2 max-3-attempts (3 TypeErrors):         PASS (calls=3)
Test3 no-retry-on-500 (HTTP error):          PASS (calls=1)
Test4 401-redirect (returns immediately):    PASS (calls=1)

Drag & drop

ui.js sets dataTransfer.setData('application/ws-path', item.path) on dragstart; item.path is server-supplied workspace tree data (ui.js:3035-3040). panels.js drop handler reads it back and inserts @<path> into msgEl.value via value=val.slice(0,start)+insert+val.slice(end) (panels.js:2116-2143). The composer is a <textarea>.value= is plain-text, not HTML; no XSS path. The downstream esc() pipeline already handles user-typed content when the message is later rendered.

The prefix=start>0&&!val[start-1].match(/\s/)?' ':''; line correctly inserts a space when the cursor is mid-word, so foo + drop → foo @path not foo@path.

Composer placeholder profile name

static/boot.js:769-784applyBotName() now picks S.activeProfile (capitalised first letter) over window._botName when S.activeProfile && S.activeProfile !== 'default'. All sinks are safe: document.title=name, sidebarH1.textContent=name, msg.placeholder=... (DOMString attribute, escaped automatically). ✅ no XSS path.

static/panels.js:1983-1984switchToProfile() calls applyBotName() after the switch so the title bar updates without a page reload.

_copyText fallback improvements

static/ui.js:1528-1551:

  1. Clipboard API path now .catch()_fallbackCopy(text) (was: rejected promise propagated to caller).
  2. _fallbackCopy extracted to its own function.
  3. Position changed from position:fixed;left:-9999px;top:-9999px to position:fixed;left:0;top:0;width:2em;height:2em;z-index:-1. Some browsers (mobile Safari) refuse execCommand('copy') on fully offscreen elements; z-index:-1 keeps it visually hidden but in viewport.
  4. Added ta.focus() before ta.select() — execCommand requires focus.
  5. Cleanup still in finally so the textarea is removed even on exception.

Other audit — things that are correct already

  • JS syntax: node --check passes on boot.js, panels.js, ui.js, workspace.js.
  • CI: green on 3.11/3.12/3.13.
  • switchToProfile calls applyBotName — locked by test_switchToProfile_calls_applyBotName.
  • OS file drop still works — locked by test_os_file_drop_still_works.
  • All env var → provider ID mappings sanity-checkedtest_all_provider_env_vars_map_to_known_providers verifies every detected_providers.add(...) references a key in _PROVIDER_MODELS or the special-providers set (openrouter/ollama/etc).
  • CSP regressiontest_existing_csp_directives_preserved locks all 9 prior directives still present.

Edge-case trace

Scenario Expected Actual
XAI_API_KEY set x-ai provider in picker ✅ test PASS
MISTRAL_API_KEY set mistralai provider in picker ✅ test PASS
config.yaml providers.anthropic.api_key set anthropic shown in picker even without env var ✅ trace verified
config.yaml providers.unknown_provider NOT added (gated by _PROVIDER_MODELS) ✅ trace verified
Idle 5+ minutes, then click session → fetch throws TypeError retry succeeds on attempt 2 ✅ harness PASS
Server returns HTTP 500 error thrown, no retry ✅ harness PASS
Server returns HTTP 401 window.location.href = /login, no retry ✅ harness PASS
3 consecutive TypeErrors throw after 3 attempts ✅ harness PASS
Drag workspace folder, drop in composer mid-word @path inserted with space prefix ✅ trace verified
Drag OS file, drop in composer attached as upload (existing path) ✅ test PASS
Profile=production, default profile picker placeholder shows "Production" ✅ trace verified
Profile=default falls back to bot_name/Hermes ✅ test PASS
Copy in Firefox without clipboard-write=(self) (was) silent fail → (now) Permissions-Policy allows it
Mermaid theme imports Google Font (was) CSP block → (now) loads

Tests

  • PR's own test files (5 new files, 34 new tests):
    • test_issue604_all_providers_model_picker.py — 12 tests
    • test_issue1097_workspace_drag_drop.py — 9 tests
    • test_issue1112_csp_google_fonts.py — 3 tests
    • test_issue1116_composer_placeholder.py — 5 tests
    • test_issue1118_idle_session_retry.py — 6 tests
    • test_issue1096_copy_buttons.py — rewritten with 8 comprehensive tests
  • CI on PR: ✅ test (3.11), ✅ test (3.12), ✅ test (3.13).
  • Local full suite: 2524 passed, 47 skipped, 0 PR-related failures.
  • Behavioural harness for api() retry: 4/4 scenarios pass (output above).

Minor observations (non-blocking)

  • The api() retry is unbounded by time — three immediate retries with no backoff. If the server is genuinely down, this hammers it three times in quick succession. For TCP-level failures (which is what TypeError typically means), retrying immediately is usually a no-op anyway, so impact is small. Could add a 100-200ms backoff if this ever causes problems in practice.
  • _fallbackCopy positions the textarea at z-index:-1 — visible-but-hidden. The textarea exists for the duration of the synchronous execCommand call only, so the user never sees it, but it's slightly more visible-in-DOM than -9999px. Trade-off is correct (some browsers refuse offscreen select()).
  • The CSP relaxation could be tightened in the future by self-hosting Google Fonts in static/, eliminating the third-party CSS dependency entirely. Not blocking; future enhancement.

Recommendation

Approved. Five contributor PRs cleanly stitched, the highest-stakes changes (CSP relaxation + clipboard Permissions-Policy + api() retry) all check out, the providers-config-scan is correctly gated against _PROVIDER_MODELS so it can't surface unknown keys, the drag-drop path uses safe sinks (textarea.value), the api() retry is correctly limited to network-level TypeErrors with a behavioural harness confirming all four edge cases. No cross-tool agent regression. Parked at approval — ready for the release agent's merge/tag pipeline.

@nesquena-hermes nesquena-hermes merged commit fc0152b into master Apr 26, 2026
3 checks passed
@nesquena-hermes nesquena-hermes deleted the stage branch April 26, 2026 22:29
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 29, 2026
…esquena#1127)

* fix(nesquena#604): model picker shows all configured providers

Two fixes to ensure the model picker surface every provider a user has
configured:

1. Added env var detection for XAI_API_KEY (→ x-ai) and MISTRAL_API_KEY
   (→ mistralai). Previously these providers were only detectable via
   hermes auth or credential pool, not via environment variables.

2. Added config.yaml providers section scanning. Users who configure
   providers in config.yaml (e.g. providers.anthropic.api_key) without
   setting the corresponding env var will now see those providers in the
   model picker. Only providers with known model catalogs are added.

- Added 12 regression tests

* fix(nesquena#1112): allow Google Fonts in CSP style-src and font-src

Mermaid themes inject @import for fonts.googleapis.com at render time.
CSP style-src blocked these requests, causing console violations.

- Add https://fonts.googleapis.com to style-src (CSS stylesheets)
- Add https://fonts.gstatic.com to font-src (WOFF2/WOFF font files)
- Add 3 regression tests + verify existing CSP tests still pass

* fix(nesquena#1118): retry api() calls on network errors after long idle

After a long idle period, the browser's TCP keep-alive connection to the
server can become stale. The next fetch() throws a TypeError (network
failure), causing 'Failed to load session' instead of transparently
reconnecting.

- Added retry loop in api() (workspace.js): up to 3 attempts
- Only retries on TypeError (network failures), NOT on HTTP errors (4xx/5xx)
- 401 redirects still fire immediately
- Added 6 regression tests

* feat(nesquena#1116): composer placeholder reflects active profile name

When a named profile is active (not 'default'), the composer placeholder
and title bar show the profile name (capitalised) instead of the global
bot_name. Falls back to bot_name/'Hermes' for the default profile.

- boot.js: applyBotName() checks S.activeProfile before _botName
- panels.js: switchToProfile() calls applyBotName() after switch
- Added 5 regression tests

* feat(nesquena#1097): drag and drop workspace files into chat composer

Files and folders in the workspace file tree are now draggable.
Dropping them into the composer inserts @path reference at cursor
position. OS file drag-and-drop (attach files) still works.

- ui.js: _renderTreeItems sets draggable + dragstart with ws-path
- panels.js: drop handler checks for application/ws-path first,
  inserts @path with smart spacing and cursor positioning
- Added 9 regression tests

* fix(nesquena#1096): copy buttons work — add clipboard-write Permissions-Policy

Copy buttons on messages and code blocks were silently failing because
the Permissions-Policy header did not include clipboard-write=(self).
Firefox blocks navigator.clipboard.writeText() without explicit permission.

- api/helpers.py: add clipboard-write=(self) to Permissions-Policy
- ui.js: _copyText now catches clipboard API errors and falls back
  to execCommand('copy'). _fallbackCopy extracted as separate function
  with proper focus() call and visible-but-hidden positioning (not -9999px)
- Added 8 regression tests

* chore: CHANGELOG for v0.50.223

---------

Co-authored-by: bergeouss <[email protected]>
Co-authored-by: nesquena-hermes <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants