v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2#1117
v0.50.221: copy HTTP fix, inline images, mobile tap, custom providers x2#1117nesquena-hermes merged 10 commits intomasterfrom
Conversation
- 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)
…vices (absorb)
nesquena
left a comment
There was a problem hiding this comment.
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 (
inagainstset), not substring —attacker.com.victim.comdoesn't accidentally matchvictim.com. - Hostnames are lower-cased on both sides — case-insensitive without case bypass.
- The SSRF block still raises
ValueErrorfor 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-1073 — onpointerup 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 clean —
node --checkpasses onsessions.js,ui.js,i18n.js. - CI green —
test (3.11)✅,test (3.12)✅,test (3.13)✅. - i18n consistency —
copy_failedkey 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.cfgandconfig._cfg_mtimeto prevent cross-test contamination. - Idempotent dedup —
test_no_duplicates_when_model_and_models_overlaplocks the in-list dedup;_seen_custom_idslocks the cross-provider dedup. - Existing-test updates —
test_issue856_pinned_indicator_layout.pyandtest_workspace_panel_session_list.pywere 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=False → ValueError 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 intest_issue1096_copy_buttons.py, 7 intest_issue1105_ssrf_custom_providers.py, 7 intest_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 callsresolve()unconditionally —document.execCommand('copy')returnsfalseon 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 wouldif (!document.execCommand('copy')) { reject(new Error(...)) }.- The 300ms navigate-timer vs 350ms double-tap-window has a 50ms gap, but the timer's
_lastTapTime=0reset 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/passwdreads. 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.
… 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]>
v0.50.221 — 5 bugfix PRs
Batch of 5 contributor PRs reviewed, fixed, and tested end-to-end.
Included PRs
_IMAGE_EXTSto module scope (wasReferenceError); addavif@media (hover:hover)restores desktop hover padding; right/middle-click filter ononpointerupGate results
node --checkclean on all modified filesCross-platform verification (#1110)
hover:none):onpointerupwith right/middle-click filter. No padding-right on:hover(prevents layout-shift mid-tap).touch-action:manipulationeliminates 300ms delay.hover:hover):@media (hover:hover)restorespadding-right:40pxon hover. Actions overlay and title truncation correct.pointerup, works on both touch and mouse.