Skip to content

v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2#1117

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

v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2#1117
nesquena-hermes merged 10 commits intomasterfrom
stage

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

v0.50.221 — 5 bugfix PRs

Batch of 5 contributor PRs reviewed, fixed, and tested end-to-end.

Included PRs

PR Title Author Absorb fixes
#1107 Copy buttons fall back to execCommand on HTTP @bergeouss None needed
#1109 Pasted/dragged images render inline @bergeouss Hoist _IMAGE_EXTS to module scope (was ReferenceError); add avif
#1110 Mobile touch responsiveness for session items @sheng-di @media (hover:hover) restores desktop hover padding; right/middle-click filter on onpointerup
#1111 Custom providers models dict iteration @bergeouss None needed
#1113 Custom providers SSRF false positive @bergeouss None needed

Gate results

  • pytest: 2511 passed, 0 failed (2481 baseline + 30 new tests)
  • QA harness (Phase 1 + Phase 2): all passed
  • Opus holistic review: REQUEST_CHANGES addressed (button filter for pointerup)
  • JS syntax: node --check clean on all modified files

Cross-platform verification (#1110)

  • Touch/iPad (hover:none): onpointerup with right/middle-click filter. No padding-right on :hover (prevents layout-shift mid-tap). touch-action:manipulation eliminates 300ms delay.
  • Mouse/Desktop (hover:hover): @media (hover:hover) restores padding-right:40px on hover. Actions overlay and title truncation correct.
  • Double-tap rename: 350ms window on pointerup, works on both touch and mouse.

bergeouss and others added 10 commits April 26, 2026 17:11
- Add _copyText() helper: tries navigator.clipboard first, falls back to
  document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
  navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
… paperclip badge

- User message attachments with image extensions now render as <img> via
  api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
  to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
  double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch

Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
  to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
  double-tap detection — works consistently on mouse and touch
…population

- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
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.221 batch — five contributor PRs absorbed and tested end-to-end:

PR Area File:line(s)
#1107 Copy fallback for HTTP / non-secure-context static/ui.js:1529-1542, i18n.js (6 locales)
#1109 Pasted/dragged image attachments render inline static/ui.js:2253-2257; _IMAGE_EXTS hoisted to module scope at L53 + adds avif
#1110 Mobile/iPad session-list tap responsiveness static/sessions.js:1030-1075, static/style.css:245-256
#1111 Custom-providers models dict iteration api/config.py:1574-1608
#1113 Custom-providers SSRF false-positive api/config.py:1502-1542

Traced against upstream hermes-agent

Pulled fresh nousresearch/hermes-agent tarball. None of the changes touch shared agent state — api/config.py modifications are entirely inside the WebUI's _build_available_models_uncached function (auto-detection of models from custom endpoints). The SSRF check runs in WebUI process before urllib.request.urlopen; agent has no equivalent path. WebUI-only fix. ✅ no cross-tool risk.

End-to-end trace

SSRF fix (highest-stakes change)

api/config.py:1502-1542 — when cfg["model"]["base_url"] is set and resolves to a private IP, the check at L1538 now allows pass-through if parsed_url.hostname.lower() in _ssrf_trusted_hosts. The trusted set is built (L1506-1516) by iterating cfg["custom_providers"] and extracting hostnames via urlparse(base_url if "://" in base_url else f"http://{base_url}").

No bypass:

  • Set membership is exact (in against set), not substring — attacker.com.victim.com doesn't accidentally match victim.com.
  • Hostnames are lower-cased on both sides — case-insensitive without case bypass.
  • The SSRF block still raises ValueError for private IPs whose hostname is neither in the hardcoded keyword check (ollama/localhost/127.0.0.1/lmstudio/lm-studio) nor in the trusted set (L1539-1542).
  • The trusted set sources from cfg.get("custom_providers", []) — if the user can write to config.yaml they already have privileged access, so this is a UX fix not a security boundary.

Custom providers models dict iteration

api/config.py:1584-1608 — collects model IDs into _cp_model_ids (singular model first, then models dict keys), with type-guard isinstance(_m_id, str) and _m_id.strip() (L1592). Dedup is two-layered: in-list (L1592 _m_id not in _cp_model_ids) and global (L1596 _cp_model not in _seen_custom_ids). For unnamed providers _slug is None and the entry falls into auto_detected_models (L1607). All paths look correct.

Mobile/iPad pointerup

static/sessions.js:1035-1073onpointerup replaces onclick+ondblclick. Right/middle-click filter at L1036 uses e.pointerType==='mouse' && e.button!==0 so touch/stylus (which always report button===0 for primary) pass through. Double-tap detection compares now - _lastTapTime < 350ms; single-tap deferred 300ms. When the 300ms timer fires it resets _lastTapTime=0, so even at 350ms-window-edge timing the next tap is treated as a fresh single-tap (verified with behavioural harness — see Tests below).

static/style.css:245-256:hover removed from the combined padding-right selector and restored only in @media (hover:hover){.session-item:hover{padding-right:40px;}}. Touch devices (hover:none) skip that block, preventing the hover-triggered layout-reflow mid-tap that was the root cause of iPad ghost-clicks.

_IMAGE_EXTS hoist

static/ui.js:53 — moved from renderMd() local-scope to module-scope, plus avif added. Used at three call sites (L960, L967, L2253). Module scope means the const RegExp is shared, but RegExp literals have no flag-state that would bleed (g flag isn't used here). Without the hoist, renderMessages() at L2253 would get ReferenceError: _IMAGE_EXTS is not defined.

static/ui.js:2248-2258 — for each attachment, extract basename via f.split('/').pop()||f, test against _IMAGE_EXTS. If image: render <img class="msg-media-img" src="${esc('api/media?path='+encodeURIComponent(f))}" alt="${esc(fname)}">. Otherwise fall through to existing paperclip badge.

XSS check: esc() applied to both imgUrl and fname. The full attachment path f is encodeURIComponentd into the query string. The _IMAGE_EXTS regex anchors to end-of-string ($), so a file like evil.png<script> does NOT pass the test (string ends with >, not .png). Even a passing extension goes through esc() for both src and alt. ✅

Copy fallback

static/ui.js:1529-1542_copyText() checks navigator.clipboard && window.isSecureContext and falls back to a hidden-textarea + document.execCommand('copy') when not in a secure context. Textarea cleanup is in finally so it runs even on exception. Used by copyMsg() (L1546) and addCopyButtons() (L2768) — both now have .catch() handlers (silent failure was the existing bug fixed here).

Other audit — things that are correct already

  • JS syntax cleannode --check passes on sessions.js, ui.js, i18n.js.
  • CI greentest (3.11) ✅, test (3.12) ✅, test (3.13) ✅.
  • i18n consistencycopy_failed key added to all 6 locales (en/ru/es/de/zh/zh-Hant) at the equivalent position in each block.
  • Test isolation — the SSRF and custom-providers functional tests properly save/restore config.cfg and config._cfg_mtime to prevent cross-test contamination.
  • Idempotent deduptest_no_duplicates_when_model_and_models_overlap locks the in-list dedup; _seen_custom_ids locks the cross-provider dedup.
  • Existing-test updatestest_issue856_pinned_indicator_layout.py and test_workspace_panel_session_list.py were updated to reflect the new :hover-only-via-@media (hover:hover) layout. Updates are correct (verified the assertions match the new CSS).

Edge-case trace

Scenario Expected Actual
Single tap on session item navigates after 300ms ✅ harness PASS
Double tap within 200ms renames, no navigate ✅ harness PASS
Two taps at 320ms (timer-vs-window edge) first nav, then second nav (no rename) ✅ harness shows clean state reset
Right-click on session item ignored ✅ harness PASS
Touch tap on session item (pointerType:'touch',button:0) navigates ✅ harness PASS
cfg.model.base_url=http://my-llama:8080, custom_providers[0].base_url=http://my-llama:8080 SSRF allows ✅ trace confirms (my-llama in _ssrf_trusted_hosts)
cfg.model.base_url=http://attacker.com resolves to private IP, no custom_providers entry SSRF blocks is_known_local=FalseValueError raised
custom_providers[].models dict has 123 (non-string key) skipped silently isinstance(_m_id, str) guard
Image attachment evil.png<script>... rendered as paperclip badge (regex doesn't match) $-anchored regex
Copy in HTTP / non-secure-context textarea+execCommand fallback ✅ trace confirms branch selection

Tests

  • Full local suite: 2463 passed, 47 skipped, 0 PR-related failures (1 pre-existing macOS-only deselect). CI on PR shows 3.11/3.12/3.13 all green.
  • PR's own test files: 5 in test_issue1095_pasted_images.py, 10 in test_issue1096_copy_buttons.py, 7 in test_issue1105_ssrf_custom_providers.py, 7 in test_issue1106_custom_providers_models.py — all pass.
  • Behavioural harness for the pointerup logic confirms all 5 timing/button-filter scenarios produce the right outcome:
    Test1 single tap: PASS  (navigated=true, renamed=false)
    Test2 double tap @200ms: PASS  (navigated=false, renamed=true)
    Test3 taps @320ms (timer-vs-window gap): clean state reset (navigated=true, renamed=false)
    Test4 right-click ignored: PASS  (no action)
    Test5 touch tap navigates: PASS
    

Minor observations (non-blocking)

  • _custom_providers_cfg = cfg.get("custom_providers", []) is read twice — once at api/config.py:1507 inside the SSRF block and once at L1574 for the named-groups iteration. Different scopes, not a bug, slight redundancy.
  • _copyText's textarea fallback calls resolve() unconditionally — document.execCommand('copy') returns false on failure but doesn't throw. A "silent" execCommand failure resolves as success. Not user-visible because the surrounding .catch() only handles thrown exceptions, but ideally would if (!document.execCommand('copy')) { reject(new Error(...)) }.
  • The 300ms navigate-timer vs 350ms double-tap-window has a 50ms gap, but the timer's _lastTapTime=0 reset closes the gap correctly (verified in harness Test 3) — no race in practice.
  • api/media?path=... endpoint that the new image-attachment src targets must validate path against the workspace root to prevent ../../etc/passwd reads. Not in this PR's scope, but worth a follow-up audit if not already done.

Recommendation

Approved. Five contributor PRs cleanly stitched together, comprehensive functional tests for the security-sensitive (SSRF) and config-shape (custom_providers) changes, behavioural harness confirms the touch-input timing logic, JS syntax clean, full suite green, no cross-tool agent regression. Parked at approval — ready for the release agent's merge/tag pipeline.

@nesquena-hermes nesquena-hermes merged commit 27b17a8 into master Apr 26, 2026
3 checks passed
@nesquena-hermes nesquena-hermes deleted the stage branch April 26, 2026 17:37
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 29, 2026
… x2 (nesquena#1117)

* fix(nesquena#1096): copy buttons fall back to execCommand on HTTP contexts

- Add _copyText() helper: tries navigator.clipboard first, falls back to
  document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
  navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests

* fix(nesquena#1095): render pasted/dragged images as inline preview instead of paperclip badge

- User message attachments with image extensions now render as <img> via
  api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests

* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)

* fix: improve mobile touch responsiveness for session list items

iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
  to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
  double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch

Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
  to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
  double-tap detection — works consistently on mouse and touch

* fix(nesquena#1106): iterate custom_providers[].models dict keys for dropdown population

- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests

* fix(nesquena#1105): allow custom_providers hostnames through SSRF check

- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)

* fix(tests): update hover padding assertions for nesquena#1110 touch fix (absorb)

* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)

* fix: filter right/middle-click from pointerup handler (absorb)

* docs: v0.50.221 release notes and version bump

---------

Co-authored-by: bergeouss <[email protected]>
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: sheng <[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.

4 participants