v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy#1127
v0.50.223: model picker, idle retry, drag-drop, CSP, clipboard copy#1127nesquena-hermes merged 7 commits intomasterfrom
Conversation
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
nesquena
left a comment
There was a problem hiding this comment.
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)
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-srclets 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-srcaddition only allows fetching font binaries fromfonts.gstatic.com; font files are not executable. script-srcis 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
_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:
- Re-throws if message contains
/401/(defensive — 401 is actually awindow.location.href + returnso it never throws). continues one instanceof TypeError && attempt<2— only retries on network failures (TypeError from fetch), not HTTP errors.- 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-784 — applyBotName() 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-1984 — switchToProfile() calls applyBotName() after the switch so the title bar updates without a page reload.
_copyText fallback improvements
- Clipboard API path now
.catch()→_fallbackCopy(text)(was: rejected promise propagated to caller). _fallbackCopyextracted to its own function.- Position changed from
position:fixed;left:-9999px;top:-9999pxtoposition:fixed;left:0;top:0;width:2em;height:2em;z-index:-1. Some browsers (mobile Safari) refuseexecCommand('copy')on fully offscreen elements;z-index:-1keeps it visually hidden but in viewport. - Added
ta.focus()beforeta.select()— execCommand requires focus. - Cleanup still in
finallyso the textarea is removed even on exception.
Other audit — things that are correct already
- JS syntax:
node --checkpasses onboot.js,panels.js,ui.js,workspace.js. - CI: green on 3.11/3.12/3.13.
switchToProfilecallsapplyBotName— locked bytest_switchToProfile_calls_applyBotName.- OS file drop still works — locked by
test_os_file_drop_still_works. - All env var → provider ID mappings sanity-checked —
test_all_provider_env_vars_map_to_known_providersverifies everydetected_providers.add(...)references a key in_PROVIDER_MODELSor the special-providers set (openrouter/ollama/etc). - CSP regression —
test_existing_csp_directives_preservedlocks 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 teststest_issue1097_workspace_drag_drop.py— 9 teststest_issue1112_csp_google_fonts.py— 3 teststest_issue1116_composer_placeholder.py— 5 teststest_issue1118_idle_session_retry.py— 6 teststest_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. _fallbackCopypositions the textarea atz-index:-1— visible-but-hidden. The textarea exists for the duration of the synchronousexecCommandcall only, so the user never sees it, but it's slightly more visible-in-DOM than-9999px. Trade-off is correct (some browsers refuse offscreenselect()).- 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.
…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]>
v0.50.223 — 5 bugfix/feature PRs
Batch reviewed, fixed, and tested end-to-end on top of v0.50.222.
Included PRs
Held PRs (not included)
S._draftsvsS.composerDrafts) + self-defeating save/delete in same function. Other changes in that PR (dblclick removal, auto-focus, background queue drain) are correct but kept together.Gate results
What ships
config.yaml providers:sectionapi()transparently retries on stale keep-alive connections (no more "Failed to load session" after idle)@pathreference