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

* fix(#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(#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(#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(#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 #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 <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
This commit is contained in:
nesquena-hermes
2026-04-26 10:36:59 -07:00
committed by GitHub
parent d67036db24
commit 27b17a8fc8
14 changed files with 646 additions and 49 deletions
+10
View File
@@ -4,6 +4,16 @@
### Fixed
## v0.50.221 — 2026-04-26
### Fixed
- **Custom providers model dropdown** — models dict keys in `custom_providers[].models` now all appear in the dropdown; previously only the singular `model` field was read. (`api/config.py`) [#1111 @bergeouss] Closes #1106
- **Custom providers SSRF false positive** — hostnames from user-configured `custom_providers[].base_url` are now trusted through the SSRF check; local inference servers (llama.cpp, vLLM, TabbyAPI) no longer blocked. (`api/config.py`) [#1113 @bergeouss] Closes #1105
- **Mobile/iPad session navigation** — tap no longer fails on first touch; replaced hover-triggered layout-shift pattern with `onpointerup` + right/middle-click filter + `touch-action:manipulation`. Desktop hover padding restored via `@media (hover:hover)` so mouse users are unaffected. (`static/sessions.js`, `static/style.css`) [#1110 @sheng-di]
- **Pasted/dragged images render inline** — image attachments now show as `<img>` with click-to-fullscreen instead of a paperclip badge. Hoisted `_IMAGE_EXTS` to module scope (was causing `ReferenceError` in `renderMessages`); added `avif` support. (`static/ui.js`) [#1109 @bergeouss] Closes #1095
- **Copy buttons on HTTP** — `_copyText()` helper checks `isSecureContext` and falls back to `execCommand('copy')` for plain-HTTP self-hosted installs. Silent failure in `addCopyButtons` fixed with error feedback. All 6 locales get `copy_failed` key. (`static/ui.js`, `static/i18n.js`) [#1107 @bergeouss] Closes #1096
## v0.50.220 — 2026-04-26
### Fixed
+1 -1
View File
@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.220 (April 26, 2026) — 2481 tests collected
> Last updated: v0.50.221 (April 26, 2026) — 2511 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
+1 -1
View File
@@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated coverage: 2481 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Automated coverage: 2511 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
+41 -15
View File
@@ -1501,6 +1501,20 @@ def get_available_models() -> dict:
import socket
# Build set of hostnames from custom_providers config — these are
# user-explicitly configured endpoints and should not be blocked by SSRF.
_ssrf_trusted_hosts: set[str] = set()
_custom_providers_cfg = cfg.get("custom_providers", [])
if isinstance(_custom_providers_cfg, list):
for _cp in _custom_providers_cfg:
if not isinstance(_cp, dict):
continue
_cp_base = (_cp.get("base_url") or "").strip()
if _cp_base:
_cp_parsed = urlparse(_cp_base if "://" in _cp_base else f"http://{_cp_base}")
if _cp_parsed.hostname:
_ssrf_trusted_hosts.add(_cp_parsed.hostname.lower())
parsed_url = urlparse(
endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}"
)
@@ -1521,7 +1535,7 @@ def get_available_models() -> dict:
"lmstudio",
"lm-studio",
)
)
) or (parsed_url.hostname or "").lower() in _ssrf_trusted_hosts
if not is_known_local:
raise ValueError(
f"SSRF: resolved hostname to private IP {addr[0]}"
@@ -1564,22 +1578,34 @@ def get_available_models() -> dict:
for _cp in _custom_providers_cfg:
if not isinstance(_cp, dict):
continue
_cp_model = _cp.get("model", "")
_cp_name = (_cp.get("name") or "").strip()
if _cp_model and _cp_model not in _seen_custom_ids:
_cp_label = _get_label_for_model(_cp_model, [])
_seen_custom_ids.add(_cp_model)
if _cp_name:
_slug = "custom:" + _cp_name.lower().replace(" ", "-")
if _slug not in _named_custom_groups:
_named_custom_groups[_slug] = (_cp_name, [])
_slug = ("custom:" + _cp_name.lower().replace(" ", "-")) if _cp_name else None
# Collect model IDs: singular "model" field first, then "models" dict keys
_cp_model_ids: list[str] = []
_cp_model = _cp.get("model", "")
if _cp_model:
_cp_model_ids.append(_cp_model)
_cp_models_dict = _cp.get("models")
if isinstance(_cp_models_dict, dict):
for _m_id in _cp_models_dict:
if isinstance(_m_id, str) and _m_id.strip() and _m_id not in _cp_model_ids:
_cp_model_ids.append(_m_id.strip())
for _cp_model in _cp_model_ids:
if _cp_model and _cp_model not in _seen_custom_ids:
_cp_label = _get_label_for_model(_cp_model, [])
_seen_custom_ids.add(_cp_model)
if _slug:
if _slug not in _named_custom_groups:
_named_custom_groups[_slug] = (_cp_name, [])
detected_providers.add(_slug)
_named_custom_groups[_slug][1].append(
{"id": _cp_model, "label": _cp_label}
)
else:
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
detected_providers.add("custom")
_named_custom_groups[_slug][1].append(
{"id": _cp_model, "label": _cp_label}
)
else:
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
detected_providers.add("custom")
_has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
if active_provider and active_provider != "custom" and not _has_custom_providers:
+6
View File
@@ -24,6 +24,7 @@ const LOCALES = {
regenerate: 'Regenerate response',
copy: 'Copy',
copied: 'Copied!',
copy_failed: 'Copy failed',
you: 'You',
thinking: 'Thinking',
expand_all: 'Expand all',
@@ -668,6 +669,7 @@ const LOCALES = {
regenerate: 'Сгенерировать ответ заново',
copy: 'Копировать',
copied: 'Скопировано!',
copy_failed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f',
you: 'Вы',
thinking: 'Думаю',
expand_all: 'Развернуть всё',
@@ -1261,6 +1263,7 @@ const LOCALES = {
regenerate: 'Regenerar respuesta',
copy: 'Copiar',
copied: '¡Copiado!',
copy_failed: 'Error al copiar',
you: 'Tú',
thinking: 'Pensando',
expand_all: 'Expandir todo',
@@ -1846,6 +1849,7 @@ const LOCALES = {
regenerate: 'Antwort regenerieren',
copy: 'Kopieren',
copied: 'Kopiert!',
copy_failed: 'Kopieren fehlgeschlagen',
you: 'Du',
thinking: 'Nachdenken',
expand_all: 'Alle ausklappen',
@@ -2212,6 +2216,7 @@ const LOCALES = {
regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u590d',
copy: '\u590d\u5236',
copied: '\u5df2\u590d\u5236',
copy_failed: '\u590d\u5236\u5931\u8d25',
you: '\u4f60',
thinking: '\u601d\u8003\u8fc7\u7a0b',
expand_all: '\u5168\u90e8\u5c55\u5f00',
@@ -2795,6 +2800,7 @@ const LOCALES = {
regenerate: '\u91cd\u65b0\u751f\u6210\u56de\u8986',
copy: '\u8907\u88fd',
copied: '\u5df2\u8907\u88fd',
copy_failed: '\u8907\u88fd\u5931\u6557',
you: '\u4f60',
thinking: '\u601d\u8003\u904e\u7a0b',
expand_all: '\u5168\u90e8\u5c55\u958b',
+26 -17
View File
@@ -1030,16 +1030,32 @@ function renderSessionListFromCache(){
actions.appendChild(menuBtn);
el.appendChild(actions);
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
// This prevents loadSession from firing on the first click of a double-click,
// which would re-render the list and destroy the dblclick target before it fires.
let _clickTimer=null;
el.onclick=async(e)=>{
if(_renamingSid) return; // ignore while any rename is active
// Use pointerup + manual double-tap detection instead of onclick/ondblclick.
// onclick/ondblclick are unreliable on touch devices (iPad Safari especially):
// hover-triggered layout shifts, ghost clicks, and 300ms delay all break
// single-tap navigation. pointerup fires immediately on both mouse & touch.
let _lastTapTime=0;
let _tapTimer=null;
el.onpointerup=(e)=>{
if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click
if(_renamingSid) return;
if(actions.contains(e.target)) return;
clearTimeout(_clickTimer);
_clickTimer=setTimeout(async()=>{
_clickTimer=null;
const now=Date.now();
if(now-_lastTapTime<350){
// Double-tap: rename
clearTimeout(_tapTimer);
_tapTimer=null;
_lastTapTime=0;
startRename();
return;
}
_lastTapTime=now;
// Single tap: wait to ensure it's not the first of a double-tap,
// then navigate
clearTimeout(_tapTimer);
_tapTimer=setTimeout(async()=>{
_tapTimer=null;
_lastTapTime=0;
if(_renamingSid) return;
// For CLI sessions, import into WebUI store first (idempotent)
if(s.is_cli_session){
@@ -1049,14 +1065,7 @@ function renderSessionListFromCache(){
}
await loadSession(s.session_id);renderSessionListFromCache();
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
}, 220);
};
el.ondblclick=async(e)=>{
e.stopPropagation();
e.preventDefault();
clearTimeout(_clickTimer); // cancel the pending single-click navigation
_clickTimer=null;
startRename();
}, 300);
};
return el;
}
+7 -2
View File
@@ -242,9 +242,14 @@
and attention indicator (26x26 at right:6px) still need 40px reserved
when they're visible covered by the hover / streaming / unread /
menu-open / focus-within rule below. */
.session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;}
.session-item.streaming,.session-item.unread,.session-item:hover,.session-item:focus-within,.session-item.menu-open{padding-right:40px;}
.session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}
.session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;}
.session-item:hover{background:var(--hover-bg);color:var(--text);}
/* Restore hover padding-right only for mouse (hover:hover) devices.
Touch/iPad (hover:none) must NOT expand padding-right on :hover
the expansion causes a layout-reflow mid-tap that moves session-actions
under the finger, triggering stopPropagation and swallowing navigation. */
@media (hover:hover){.session-item:hover{padding-right:40px;}}
.session-item.active{background:var(--accent-bg);color:var(--accent);}
.session-item.streaming .session-title{color:var(--accent);}
.session-item.streaming .session-title-row{color:var(--text);}
+26 -6
View File
@@ -50,6 +50,7 @@ function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
@@ -747,7 +748,6 @@ function renderMd(raw){
// Detect MEDIA:<path-or-url> tokens emitted by the agent (e.g. screenshots,
// generated images) and replace them with inline <img> or download links.
// Stashed so the path/URL is never processed as markdown.
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico)$/i;
const media_stash=[];
s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{
media_stash.push(raw_ref);
@@ -1526,14 +1526,27 @@ function showPromptDialog(opts={}){
}
function _copyText(text){
if(navigator.clipboard && window.isSecureContext){
return navigator.clipboard.writeText(text);
}
return new Promise((resolve,reject)=>{
const ta=document.createElement('textarea');
ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';
document.body.appendChild(ta);ta.select();
try{document.execCommand('copy');resolve();}
catch(e){reject(e);}
finally{document.body.removeChild(ta);}
});
}
function copyMsg(btn){
const row=btn.closest('[data-raw-text]');
const text=row?row.dataset.rawText:'';
if(!text)return;
navigator.clipboard.writeText(text).then(()=>{
_copyText(text).then(()=>{
const orig=btn.innerHTML;btn.innerHTML=li('check',13);btn.style.color='var(--blue)';
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
}).catch(()=>showToast('Copy failed'));
}).catch(()=>showToast(t('copy_failed')));
}
// ── Reconnect banner (B4/B5: reload resilience) ──
@@ -2235,7 +2248,14 @@ function renderMessages(){
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
let filesHtml='';
if(m.attachments&&m.attachments.length){
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
filesHtml=`<div class="msg-files">${m.attachments.map(f=>{
const fname=f.split('/').pop()||f;
if(_IMAGE_EXTS.test(fname)){
const imgUrl='api/media?path='+encodeURIComponent(f);
return `<img class="msg-media-img" src="${esc(imgUrl)}" alt="${esc(fname)}" loading="lazy" onclick="this.classList.toggle('msg-media-img--full')">`;
}
return `<div class="msg-file-badge">${li('paperclip',12)} ${esc(fname)}</div>`;
}).join('')}</div>`;
}
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
@@ -2745,10 +2765,10 @@ function addCopyButtons(container){
btn.textContent=t('copy');
btn.onclick=(e)=>{
e.stopPropagation();
navigator.clipboard.writeText(codeEl.textContent).then(()=>{
_copyText(codeEl.textContent).then(()=>{
btn.textContent=t('copied');
setTimeout(()=>{btn.textContent=t('copy');},1500);
});
}).catch(()=>{btn.textContent=t('copy_failed');setTimeout(()=>{btn.textContent=t('copy');},1500);});
};
const header=pre.previousElementSibling;
if(header&&header.classList.contains('pre-header')){
+52
View File
@@ -0,0 +1,52 @@
"""Tests for #1095 — pasted images render as inline previews, not paperclip badges."""
import os
import re
import pytest
def _read_js(name):
with open(os.path.join('static', name)) as f:
return f.read()
class TestAttachmentImageRendering:
"""User message attachments with image extensions should render as <img>, not paperclip badges."""
def test_attachments_block_uses_image_check(self):
ui = _read_js('ui.js')
# Find the attachments rendering block
assert 'm.attachments' in ui
# Must check file extension before rendering
assert '_IMAGE_EXTS.test(' in ui, '_IMAGE_EXTS not used in attachment rendering'
def test_image_attachments_use_img_tag(self):
"""Image attachments should produce <img> with api/media?path=, not paperclip badge."""
ui = _read_js('ui.js')
# Find the attachments section
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
assert m, 'attachments rendering block not found'
body = ui[m.start():m.start() + 1000]
# Should have img tag with api/media
assert 'msg-media-img' in body, 'attachments must render images with msg-media-img class'
assert 'api/media?path=' in body, 'image attachments must use api/media endpoint'
def test_non_image_attachments_keep_paperclip(self):
"""Non-image attachments must still show paperclip badge."""
ui = _read_js('ui.js')
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
body = ui[m.start():m.start() + 1000]
assert "msg-file-badge" in body, 'non-image attachments must still use paperclip badge'
def test_image_click_to_full(self):
"""Inline image attachments should support click-to-fullscreen (toggle class)."""
ui = _read_js('ui.js')
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
body = ui[m.start():m.start() + 1000]
assert "msg-media-img--full" in body, 'image attachments should toggle full-screen on click'
def test_uses_filename_not_full_path(self):
"""Non-image badge should display filename, not full path."""
ui = _read_js('ui.js')
m = re.search(r"m\.attachments&&m\.attachments\.length", ui)
body = ui[m.start():m.start() + 1000]
assert ".split('/').pop()" in body, 'should extract filename from path for display'
+100
View File
@@ -0,0 +1,100 @@
"""Tests for #1096 — copy buttons work without HTTPS (execCommand fallback)."""
import os
import re
import pytest
# ── Helpers ─────────────────────────────────────────────────────────────────────
def _read_js(name):
path = os.path.join('static', name)
with open(path) as f:
return f.read()
def _read_i18n():
return _read_js('i18n.js')
def _read_ui():
return _read_js('ui.js')
# ── _copyText helper ────────────────────────────────────────────────────────────
class TestCopyTextHelperExists:
"""_copyText() helper must exist with secure context check + execCommand fallback."""
def test_function_exists(self):
ui = _read_ui()
assert 'function _copyText(' in ui, '_copyText helper missing from ui.js'
def test_uses_secure_context_check(self):
ui = _read_ui()
assert 'isSecureContext' in ui, 'isSecureContext check missing — fallback wont trigger on HTTP'
def test_uses_exec_command_fallback(self):
ui = _read_ui()
assert 'execCommand' in ui, 'execCommand fallback missing — copy fails on HTTP'
# ── copyMsg() uses helper ──────────────────────────────────────────────────────
class TestCopyMsgUsesHelper:
def test_copy_msg_exists(self):
ui = _read_ui()
assert 'function copyMsg(' in ui
def test_copy_msg_calls_helper_not_clipboard_directly(self):
"""copyMsg must use _copyText, not navigator.clipboard.writeText."""
ui = _read_ui()
# Find copyMsg function body
m = re.search(r'function copyMsg\(', ui)
assert m, 'copyMsg function not found'
body = ui[m.start():m.start() + 600]
assert '_copyText(' in body, 'copyMsg must call _copyText helper'
assert 'navigator.clipboard.writeText' not in body, 'copyMsg must NOT call navigator.clipboard directly'
def test_copy_msg_has_i18n_error(self):
"""Error message must use t() for i18n, not hardcoded string."""
ui = _read_ui()
m = re.search(r'function copyMsg\(', ui)
body = ui[m.start():m.start() + 600]
assert "t('copy_failed')" in body, 'copyMsg error must use t("copy_failed") not hardcoded string'
# ── Code block copy uses helper ────────────────────────────────────────────────
class TestCodeBlockCopyUsesHelper:
def test_add_copy_buttons_exists(self):
ui = _read_ui()
assert 'function addCopyButtons(' in ui
def test_code_copy_calls_helper(self):
"""addCopyButtons must use _copyText, not navigator.clipboard directly."""
ui = _read_ui()
m = re.search(r'function addCopyButtons\(', ui)
assert m, 'addCopyButtons function not found'
body = ui[m.start():m.start() + 2000]
assert '_copyText(' in body, 'addCopyButtons must call _copyText helper'
assert 'navigator.clipboard.writeText' not in body, 'addCopyButtons must NOT call navigator.clipboard directly'
def test_code_copy_has_catch_handler(self):
"""Code block copy must have .catch() — previously had none (silent failure)."""
ui = _read_ui()
m = re.search(r'function addCopyButtons\(', ui)
body = ui[m.start():m.start() + 2000]
assert '.catch(' in body, 'Code block copy button has no .catch() handler'
# ── i18n ────────────────────────────────────────────────────────────────────────
class TestCopyFailedI18n:
def test_copy_failed_in_all_locales(self):
"""copy_failed key must exist in all 6 locale blocks."""
i18n = _read_i18n()
count = i18n.count('copy_failed')
assert count == 6, f'Expected copy_failed in 6 locale blocks, found {count}'
@@ -0,0 +1,148 @@
"""Tests for #1105 — SSRF check allows user-configured custom_providers hostnames.
The SSRF check blocks requests to private IPs unless the hostname is in a
hardcoded allowlist. This fix extracts hostnames from custom_providers config
and adds them to the trusted set, so user-explicitly configured local endpoints
(ollama, llama.cpp, vLLM, TabbyAPI, etc.) are not blocked.
"""
import os
import pytest
# ---------- Source-code analysis tests ----------
def test_ssrf_trusted_hosts_variable_exists():
"""The _ssrf_trusted_hosts set must be built from custom_providers config."""
with open("api/config.py") as f:
src = f.read()
assert "_ssrf_trusted_hosts" in src
assert "_ssrf_trusted_hosts: set[str] = set()" in src
def test_ssrf_trusted_hosts_populated_from_custom_providers():
"""Trusted hosts are extracted by iterating custom_providers[].base_url."""
with open("api/config.py") as f:
src = f.read()
# Must read custom_providers from cfg
assert 'cfg.get("custom_providers"' in src
# Must extract base_url from each entry
assert '_cp.get("base_url")' in src
# Must parse hostname with urlparse
assert "_cp_parsed.hostname" in src
# Must add to trusted set
assert "_ssrf_trusted_hosts.add" in src
def test_ssrf_check_uses_trusted_hosts():
"""The SSRF check must consult _ssrf_trusted_hosts before blocking."""
with open("api/config.py") as f:
src = f.read()
# The is_known_local check must include _ssrf_trusted_hosts
assert "in _ssrf_trusted_hosts" in src
def test_ssrf_known_local_still_present():
"""Original hardcoded allowlist must still be present (no regression)."""
with open("api/config.py") as f:
src = f.read()
for keyword in ("ollama", "localhost", "127.0.0.1", "lmstudio", "lm-studio"):
assert keyword in src, f"Missing hardcoded allowlist entry: {keyword}"
def test_ssrf_block_still_present():
"""SSRF ValueError must still be raised for unknown private IPs."""
with open("api/config.py") as f:
src = f.read()
assert 'SSRF: resolved hostname to private IP' in src
# ---------- Functional tests (mocked socket) ----------
def test_custom_provider_hostname_added_to_trusted():
"""A hostname from custom_providers base_url is added to trusted set."""
import api.config as config
import socket
from unittest.mock import patch, MagicMock
old_cfg = dict(config.cfg)
try:
config.cfg.update({
"model": {"model": "my-model", "base_url": "http://my-llama-server:8080/v1"},
"custom_providers": [
{"name": "my-llama", "base_url": "http://my-llama-server:8080/v1", "model": "llama-3"}
],
"providers": {},
})
config.invalidate_models_cache()
# Mock socket.getaddrinfo to return a private IP for my-llama-server
private_addr = ("192.168.1.100", None)
mock_getaddrinfo = MagicMock(return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", private_addr)
])
# Mock urllib to prevent actual HTTP call
mock_urlopen = MagicMock()
mock_urlopen.read.return_value = b'{"data": [{"id": "test-model"}]}'
mock_urlopen.__enter__ = MagicMock(return_value=mock_urlopen)
mock_urlopen.__exit__ = MagicMock(return_value=False)
with patch("socket.getaddrinfo", mock_getaddrinfo), \
patch("urllib.request.urlopen", mock_urlopen):
# Should NOT raise ValueError (SSRF) because hostname is in trusted set
result = config.get_available_models()
# Verify models were returned (auto-detection succeeded)
assert result is not None
assert "groups" in result
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config.invalidate_models_cache()
def test_unknown_private_ip_still_blocked():
"""A private IP from a hostname NOT in custom_providers is still blocked.
The SSRF ValueError is caught by the broad `except Exception` around
the custom endpoint fetch (line ~1571), so get_available_models() doesn't
crash but no models are auto-detected from that endpoint.
"""
import api.config as config
import socket
from unittest.mock import patch, MagicMock
old_cfg = dict(config.cfg)
try:
config.cfg.update({
"model": {"model": "test", "base_url": "http://unknown-local-server:9999/v1"},
"custom_providers": [
{"name": "other", "base_url": "http://other-server:8080/v1", "model": "x"}
],
"providers": {},
})
config.invalidate_models_cache()
# Mock socket.getaddrinfo to return a private IP for unknown-local-server
private_addr = ("10.0.0.50", None)
mock_getaddrinfo = MagicMock(return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", private_addr)
])
with patch("socket.getaddrinfo", mock_getaddrinfo):
# Should NOT crash (ValueError is caught internally)
result = config.get_available_models()
# But no models should be auto-detected from the blocked endpoint
assert result is not None
assert "groups" in result
# Verify no group with "unknown-local-server" models exists
for group in result["groups"]:
provider_name = group.get("provider", "")
assert "unknown-local-server" not in provider_name
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config.invalidate_models_cache()
@@ -0,0 +1,198 @@
"""Tests for #1106 — custom_providers[].models dict keys populate model dropdown."""
import pytest
import api.config as config
def _reset():
try:
config.invalidate_models_cache()
except Exception:
pass
def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None):
"""Temporarily patch config.cfg, call get_available_models(), restore.
Also pins _cfg_mtime to prevent reload_config() from overwriting patches.
"""
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
if model_cfg:
config.cfg["model"] = model_cfg
if custom_providers is not None:
config.cfg["custom_providers"] = custom_providers
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
def _all_model_ids(result):
"""Extract all model IDs from all groups."""
ids = []
for g in result.get("groups", []):
for m in g.get("models", []):
ids.append(m["id"])
return ids
def _group_for(result, provider_name):
"""Get a group by provider name."""
for g in result.get("groups", []):
if g.get("provider") == provider_name:
return g
return None
class TestCustomProvidersModelsDict:
"""custom_providers entries with a 'models' dict should populate all keys in the dropdown."""
def test_models_dict_keys_appear_in_dropdown(self):
"""Each key in custom_providers[].models should appear as a selectable model."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "Llama-swap",
"base_url": "http://llama-swap:8880/v1",
"model": "unsloth-qwen3.6-35b-a3b",
"models": {
"unsloth-qwen3.6-35b-a3b": {"context_length": 262144},
"gemma4-26b": {},
"qwen3.5-27b": {},
"qwen3-coder-30b": {},
},
}
],
)
ids = _all_model_ids(result)
for expected in ["unsloth-qwen3.6-35b-a3b", "gemma4-26b", "qwen3.5-27b", "qwen3-coder-30b"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_models_dict_without_model_field_still_works(self):
"""If only 'models' dict is present (no singular 'model'), all dict keys should appear."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "Local-LLM",
"base_url": "http://localhost:8080/v1",
"models": {
"llama-3-8b": {},
"mistral-7b": {},
},
}
],
)
ids = _all_model_ids(result)
assert "llama-3-8b" in ids
assert "mistral-7b" in ids
def test_no_duplicates_when_model_and_models_overlap(self):
"""If 'model' value also appears in 'models' dict, it should not be duplicated."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "MyServer",
"base_url": "http://myserver:8000/v1",
"model": "base-model",
"models": {
"base-model": {},
"other-model": {},
},
}
],
)
ids = _all_model_ids(result)
assert ids.count("base-model") == 1, f"'base-model' should appear exactly once, got {ids.count('base-model')}"
assert "other-model" in ids
def test_unnamed_provider_models_dict_works(self):
"""custom_providers without 'name' should still populate 'Custom' group."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"model": "my-model",
"models": {
"extra-model-a": {},
"extra-model-b": {},
},
}
],
)
ids = _all_model_ids(result)
for expected in ["my-model", "extra-model-a", "extra-model-b"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_empty_models_dict_is_ignored(self):
"""An empty 'models' dict should not break anything."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "TestServer",
"model": "only-model",
"models": {},
}
],
)
ids = _all_model_ids(result)
assert "only-model" in ids
def test_non_string_models_keys_are_skipped(self):
"""Non-string keys in models dict should be silently skipped."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "TestServer",
"model": "valid-model",
"models": {
"another-valid": {},
123: {}, # non-string key
None: {}, # non-string key
},
}
],
)
ids = _all_model_ids(result)
assert "valid-model" in ids
assert "another-valid" in ids
def test_multiple_custom_providers_each_keep_models_separate(self):
"""Multiple named custom_providers should each have their own models."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "Server-A",
"model": "model-a1",
"models": {"model-a2": {}},
},
{
"name": "Server-B",
"model": "model-b1",
"models": {"model-b2": {}},
},
],
)
group_a = _group_for(result, "Server-A")
group_b = _group_for(result, "Server-B")
assert group_a is not None, "Server-A group missing"
assert group_b is not None, "Server-B group missing"
ids_a = [m["id"] for m in group_a["models"]]
ids_b = [m["id"] for m in group_b["models"]]
assert "model-a1" in ids_a and "model-a2" in ids_a
assert "model-b1" in ids_b and "model-b2" in ids_b
# No cross-contamination
assert "model-b1" not in ids_a
assert "model-a1" not in ids_b
+19 -1
View File
@@ -90,7 +90,14 @@ def test_timestamp_hidden_when_attention_state_is_present():
# focus-within all expand to 40px to make room for the absolute action
# button + attention indicator.
assert ".session-item{padding:8px 8px;" in STYLE_CSS
assert ".session-item.streaming,.session-item.unread,.session-item:hover,.session-item:focus-within,.session-item.menu-open{padding-right:40px;}" in STYLE_CSS
# PR #1110: :hover removed from the COMBINED padding-right rule (touch layout-shift fix).
# Instead, hover padding is restored via @media (hover:hover) which only applies to
# devices with a real hover capability (mouse). Touch/iPad devices satisfy hover:none
# and skip that block, preventing the layout-reflow mid-tap bug.
assert ".session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;}" in STYLE_CSS
# Desktop hover padding restored via media query (mouse devices only)
assert "@media (hover:hover)" in STYLE_CSS
assert ".session-item:hover{padding-right:40px;}" in STYLE_CSS
assert ".session-item{min-height:44px;padding:10px 40px 10px 12px;}" in STYLE_CSS
# Timestamp now uses margin-left:auto inside the flex row instead of
# absolute positioning. This stops the title's flex:1 bound from running
@@ -142,3 +149,14 @@ def test_apperror_path_calls_render_session_list():
"apperror handler must call renderSessionList() so the streaming indicator "
"clears immediately on server errors, not after a 5s poll delay"
)
def test_pointerup_ignores_non_primary_mouse_buttons():
"""Right-click and middle-click must not trigger session navigation.
onpointerup fires for all mouse buttons; we filter to button===0
(primary). pointerType==='mouse' scopes the check to mouse only
touch/stylus always report button===0 so they're unaffected."""
assert "e.pointerType==='mouse' && e.button!==0" in SESSIONS_JS, (
"pointerup handler must filter out non-primary mouse buttons "
"(right-click / middle-click must not navigate)"
)
+11 -6
View File
@@ -220,18 +220,23 @@ class TestProjectDotPlacement:
assert ".session-item{min-height:44px;padding:10px 40px 10px 12px;}" in STYLE_CSS
def test_session_item_expands_padding_on_hover_and_attention(self):
"""When hover/focus/menu-open/streaming/unread shows the action
button or attention indicator, padding-right expands to 40px to
reserve space for them (they're position:absolute at right:6px,
26px wide 32px footprint, 40px gives 8px breathing)."""
"""PR #1110: Touch layout-shift fix — :hover removed from the COMBINED
padding-right selector. Touch devices (iPad, phone) see hover:none so
they skip the @media (hover:hover) block below. Mouse devices see
hover:hover and get the padding-right on hover.
streaming/unread/focus-within/menu-open expand to 40px for all devices."""
# Touch-safe combined rule (no :hover in this one)
sel = (
".session-item.streaming,.session-item.unread,"
".session-item:hover,.session-item:focus-within,"
".session-item:focus-within,"
".session-item.menu-open"
)
idx = STYLE_CSS.find(sel)
assert idx >= 0, (
f"Combined hover/streaming/unread padding rule not found"
"Combined streaming/unread/focus-within/menu-open padding rule not found"
)
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
assert "padding-right:40px" in rule
# Desktop hover padding restored via @media (hover:hover) — mouse devices only
assert "@media (hover:hover)" in STYLE_CSS
assert ".session-item:hover{padding-right:40px;}" in STYLE_CSS