From 3a8f980e2bebadf9da84a17b3cf3d2a663dff777 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 11 Jul 2026 16:46:13 -0700 Subject: [PATCH] Release exp-v0.52.44: Docker/no-git image update notices (#5733, #2729) (#5958) * fix(#2729): show Docker image update notices * Release exp-v0.52.44: show Docker/no-git image update notices (#5733, #2729) --------- Co-authored-by: Rod Boev Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/updates.py | 104 ++++++++++++++++++++++++- static/panels.js | 8 +- static/ui.js | 29 ++++++- tests/test_update_banner_fixes.py | 124 ++++++++++++++++++++++++++++++ tests/test_updates.py | 69 +++++++++++++++++ 6 files changed, 331 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6640ac49..d69645375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +- **Docker / no-git installs now get an update notice.** Container and other non-git WebUI installs previously had no way to see that a newer release existed (the in-app updater only works on git checkouts). The Settings update panel now shows an informational notice when the running image trails the latest published release — e.g. "WebUI (v0.52.30 → v0.52.43): 13 releases available · Manual update required: run `docker pull …`, then recreate the container." It is informational only: the Force-update and Clear-lock actions are disabled/hidden for these installs and the notice can never trigger a git reset. Prerelease tags are ignored so stable installs only see stable releases. Thanks @rodboev. (#5733, #2729) + - **Reverse-proxy SSO via trusted identity headers (opt-in, off by default).** You can now put the WebUI behind a reverse proxy you run (Authelia, oauth2-proxy, corporate SSO, …) that authenticates the user and forwards their identity in a header. Set `HERMES_WEBUI_TRUSTED_AUTH_HEADER` (e.g. `Remote-User`) plus `HERMES_WEBUI_TRUSTED_PROXY_CIDRS` with the **exact** IP(s) of your proxy — the header is honored *only* when the request's un-spoofable socket peer is an allowlisted proxy, so a direct client can't forge it. Optional group→profile mapping (`HERMES_WEBUI_GROUP_PROFILE_MAP`, JSON) and SSO single-logout URL are supported. Fails closed on empty/malformed config; existing password/passkey auth is unchanged when this is not configured. See `.env.example` for the full setup and the security warning. Thanks @rodboev. (#5568, #3351) - **Deep-link straight to a profile with `?profile=`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682) diff --git a/api/updates.py b/api/updates.py index b5698b5da..5c37c67ad 100644 --- a/api/updates.py +++ b/api/updates.py @@ -58,6 +58,7 @@ _FETCH_NETWORK_FAILURE_SIGNATURES = ( 'tls connection was non-properly terminated', 'ssl certificate problem', ) +_RELEASE_TAG_RE = re.compile(r'^v[0-9][0-9A-Za-z.+-]*$') # Phrases git emits when its own short-lived index/refs lock files block a # subsequent operation. Tuned to match only the true "lock file already exists" # semantics that warrant a lock-conflict response -- v2 deliberately drops the @@ -272,7 +273,7 @@ def _inventory_locks(path: Path) -> dict: try: for entry in sorted(git_dir.rglob('*.lock')): try: - rel = str(entry.relative_to(git_dir)) + rel = entry.relative_to(git_dir).as_posix() except ValueError: continue if rel == 'index.lock': @@ -787,6 +788,101 @@ def _count_channel_tags_ahead(path, channel=DEFAULT_UPDATE_CHANNEL): return sum(1 for line in out.splitlines() if line.strip()) +def _release_tag_sort_key(tag): + """Return a version-sort key that keeps release tags newest-first.""" + raw = str(tag or '').strip() + if raw.startswith('v'): + raw = raw[1:] + parts = [] + for chunk in re.split(r'(\d+)', raw): + if not chunk: + continue + parts.append((0, int(chunk)) if chunk.isdigit() else (1, chunk.lower())) + return tuple(parts) + + +def _is_stable_release_tag(tag): + """Return True for stable release tags and False for prerelease tags.""" + raw = str(tag or '').strip() + return bool(_RELEASE_TAG_RE.fullmatch(raw) and '-' not in raw[1:]) + + +def _github_release_tags(url='https://api.github.com/repos/nesquena/hermes-webui/tags?per_page=100', *, timeout=3.0): + """Return GitHub release tags newest-first, including commit SHAs when available.""" + request = urllib.request.Request( + url, + headers={ + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'hermes-webui', + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode('utf-8')) + if not isinstance(payload, list): + return [] + tags = [] + for item in payload: + if not isinstance(item, dict): + continue + name = item.get('name') + if not isinstance(name, str): + continue + name = name.strip() + if not _is_stable_release_tag(name): + continue + commit = item.get('commit') + sha = None + if isinstance(commit, dict): + commit_sha = commit.get('sha') + if isinstance(commit_sha, str): + commit_sha = commit_sha.strip() + if commit_sha: + sha = commit_sha + tags.append({'name': name, 'sha': sha}) + return sorted(tags, key=lambda item: _release_tag_sort_key(item['name']), reverse=True) + + +def _check_webui_published_release_update(): + """Return a manual-update payload when the baked WebUI version trails GitHub tags.""" + current_version = str(WEBUI_VERSION or '').strip() + if not _RELEASE_TAG_RE.fullmatch(current_version): + return None + try: + tags = _github_release_tags() + except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError, UnicodeDecodeError, ValueError): + return None + if not tags: + return None + + tag_names = [item['name'] for item in tags] + if current_version not in tag_names: + return None + + latest = tags[0] + latest_version = latest['name'] + behind = _release_gap(tag_names, current_version, latest_version) + if behind <= 0: + return None + + current = next((item for item in tags if item['name'] == current_version), None) or {} + current_ref = current.get('sha') or current_version + latest_ref = latest.get('sha') or latest_version + repo_url = 'https://github.com/nesquena/hermes-webui' + return { + 'name': 'webui', + 'behind': behind, + 'current_sha': current_ref, + 'latest_sha': latest_ref, + 'branch': latest_version, + 'repo_url': repo_url, + 'release_based': True, + 'current_version': current_version, + 'latest_version': latest_version, + 'compare_url': _build_compare_url(repo_url, current_ref, latest_ref), + 'manual_update': True, + } + + def _head_is_past_latest_tag(path, current_tag, channel=DEFAULT_UPDATE_CHANNEL): """Return True when HEAD has moved past the latest reachable channel tag. @@ -1112,6 +1208,12 @@ def _check_repo(path, name, channel=DEFAULT_UPDATE_CHANNEL): """ channel = _normalize_channel(channel) if path is None or not (path / '.git').exists(): + if name == 'webui': + release_info = _check_webui_published_release_update() + if release_info is not None: + release_info = dict(release_info) + release_info['no_git'] = True + return release_info return { 'name': name, 'behind': None, diff --git a/static/panels.js b/static/panels.js index 6ebbf2bac..6c4649f3d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -11905,14 +11905,20 @@ async function checkUpdatesNow(channelOverride){ const agentPart=formatUpdatePart('Agent',data.agent); if(webuiPart) parts.push(webuiPart); if(agentPart) parts.push(agentPart); + const manualInstruction=(typeof _formatManualUpdateInstruction==='function') + ? _formatManualUpdateInstruction(data.webui) + : (data.webui&&data.webui.no_git&&data.webui.manual_update&&data.webui.behind>0 + ? 'Manual update required: run docker pull ghcr.io/nesquena/hermes-webui:latest, then recreate the container.' + : null); // Track non-git targets separately so a mixed deployment (one git // checkout + one no-git install) never hides the "can't check" state // behind an up-to-date summary (#4356). const noGitParts=[]; - if(data.webui&&data.webui.no_git) noGitParts.push('WebUI'); + if(data.webui&&data.webui.no_git&&!data.webui.manual_update) noGitParts.push('WebUI'); if(data.agent&&data.agent.no_git&&!data.agent.ignored) noGitParts.push('Agent'); if(parts.length){ let txt=t('settings_updates_available').replace('{count}',parts.join(', ')); + if(manualInstruction) txt+=' · '+manualInstruction; if(noGitParts.length) txt+=' · '+t('settings_update_no_git'); if(status){status.textContent=txt;status.style.color='var(--accent)';} // Also trigger the update banner diff --git a/static/ui.js b/static/ui.js index ccf2bc9b5..3a77526e9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9140,13 +9140,18 @@ async function refreshSession() { } // ── Update banner ── function _formatUpdateTargetStatus(label,info){ - if(!info||info.no_git||!(info.behind>0)) return null; + const manualNoGit=!!(info&&info.no_git&&info.manual_update&&info.behind>0); + if(!info||(info.no_git&&!manualNoGit)||!(info.behind>0)) return null; const release=(info.release_based&&info.latest_version) ?` (${info.current_version||'unknown'} -> ${info.latest_version})` :(info.branch?` (${info.branch})`:''); const noun=info.release_based?'release':'update'; return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`; } +function _formatManualUpdateInstruction(info){ + if(!(info&&info.no_git&&info.manual_update&&info.behind>0)) return null; + return 'Manual update required: run docker pull ghcr.io/nesquena/hermes-webui:latest, then recreate the container.'; +} function _formatUpdateCheckError(label,info){ if(!info||!info.error) return null; const detail=String(info.error).replace(/^fetch failed:?\s*/i,'').trim(); @@ -9453,6 +9458,21 @@ function _showUpdateBanner(data){ if(webuiPart) parts.push(webuiPart); if(agentPart) parts.push(agentPart); window._updateData=data; + const btnApply=$('btnApplyUpdate'); + if(btnApply){ + const webuiManual=!!(data&&data.webui&&data.webui.manual_update&&data.webui.behind>0); + const webuiUpdatable=!!(data&&data.webui&&data.webui.behind>0&&!webuiManual); + const agentUpdatable=!!(data&&data.agent&&data.agent.behind>0); + const hasApplyTargets=webuiUpdatable||agentUpdatable; + btnApply.disabled=!hasApplyTargets; + btnApply.style.display=hasApplyTargets?'':'none'; + if(webuiManual){ + const forceBtn=$('btnForceUpdate'); + if(forceBtn){forceBtn.disabled=true;forceBtn.style.display='none';forceBtn.dataset.target='';} + const clearLockBtn=$('btnClearUpdateLock'); + if(clearLockBtn){clearLockBtn.disabled=true;clearLockBtn.style.display='none';clearLockBtn.dataset.target='';} + } + } if(!parts.length){ _renderUpdateWhatsNewLinks(data); const staleBanner=$('updateBanner'); @@ -9460,7 +9480,10 @@ function _showUpdateBanner(data){ return; } const msg=$('updateMsg'); - if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available'; + if(msg){ + const manualInstruction=_formatManualUpdateInstruction(data&&data.webui); + msg.textContent='\u2B06 '+parts.join(', ')+' available'+(manualInstruction?' · '+manualInstruction:''); + } const banner=$('updateBanner'); if(banner) banner.classList.add('visible'); const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff'; @@ -9511,7 +9534,7 @@ async function applyUpdates(){ if(forceBtnReset){forceBtnReset.style.display='none';forceBtnReset.dataset.target='';} const targets=[]; if(window._updateData?.agent?.behind>0) targets.push('agent'); - if(window._updateData?.webui?.behind>0) targets.push('webui'); + if(window._updateData?.webui?.behind>0&&!window._updateData?.webui?.manual_update) targets.push('webui'); if(!targets.length){ const msg=updateText('update_no_target','No update target selected. Refresh update status and retry.'); if(errEl){errEl.textContent=msg;errEl.style.display='block';} diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index b9f7610e8..58383b281 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -1776,6 +1776,130 @@ class TestUpdateBannerUx: assert '_formatUpdateTargetStatus' in fn assert "formatUpdatePart('WebUI',data.webui)" in fn assert "formatUpdatePart('Agent',data.agent)" in fn + assert "data.webui&&data.webui.no_git&&!data.webui.manual_update" in fn + + def test_manual_webui_no_git_updates_are_bannerable_but_plain_no_git_stays_hidden(self): + src = read('static/ui.js') + format_fn = extract_js_function(src, '_formatUpdateTargetStatus') + instruction_fn = extract_js_function(src, '_formatManualUpdateInstruction') + script = f""" +{format_fn} +{instruction_fn} +const manual=_formatUpdateTargetStatus('WebUI', {{ + no_git: true, + manual_update: true, + behind: 1, + release_based: true, + current_version: 'v0.51.833', + latest_version: 'v0.51.913', +}}); +if(manual !== 'WebUI (v0.51.833 -> v0.51.913): 1 release') throw new Error('manual webui update must be bannerable: '+manual); +const instruction=_formatManualUpdateInstruction({{ no_git: true, manual_update: true, behind: 1 }}); +if(!instruction || instruction.indexOf('docker pull ghcr.io/nesquena/hermes-webui:latest') === -1) throw new Error('manual webui update must include pull guidance: '+instruction); +if(_formatManualUpdateInstruction({{ no_git: true, behind: 1 }}) !== null) throw new Error('plain no-git webui must not show manual guidance'); +if(_formatUpdateTargetStatus('WebUI', {{ no_git: true, behind: 1 }}) !== null) throw new Error('plain no-git webui must stay hidden'); +""".strip() + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_manual_webui_banner_hides_apply_button(self): + src = read('static/ui.js') + format_fn = extract_js_function(src, '_formatUpdateTargetStatus') + instruction_fn = extract_js_function(src, '_formatManualUpdateInstruction') + show_fn = extract_js_function(src, '_showUpdateBanner') + script = f""" +const state = {{ + updateBanner: {{ classList: {{ added: false, add() {{ this.added = true; }}, remove() {{ this.removed = true; }} }} }}, + updateMsg: {{ textContent: '' }}, + btnApplyUpdate: {{ disabled: false, style: {{ display: '' }} }}, + btnForceUpdate: {{ disabled: false, style: {{ display: 'inline-block' }}, dataset: {{ target: 'agent' }} }}, + btnClearUpdateLock: {{ disabled: false, style: {{ display: 'inline-block' }}, dataset: {{ target: 'agent' }} }}, + updateWhatsNewLinks: {{ style: {{ display: 'none' }}, replaceChildren() {{ this.cleared = true; }} }}, +}}; +global.window = {{}}; +global.$ = (id) => state[id] || null; +global._renderUpdateWhatsNewLinks = () => {{}}; +{format_fn} +{instruction_fn} +{show_fn} +_showUpdateBanner({{ + webui: {{ + no_git: true, + manual_update: true, + behind: 1, + release_based: true, + current_version: 'v0.51.833', + latest_version: 'v0.51.913', + compare_url: 'https://github.com/nesquena/hermes-webui/compare/current-sha...latest-sha', + }}, + agent: null, +}}); +if(state.updateMsg.textContent.indexOf('WebUI') === -1) throw new Error('manual update must still render banner text'); +if(state.updateMsg.textContent.indexOf('docker pull ghcr.io/nesquena/hermes-webui:latest') === -1) throw new Error('manual update must render pull guidance'); +if(state.btnApplyUpdate.style.display !== 'none') throw new Error('manual webui update must hide the apply button'); +if(state.btnApplyUpdate.disabled !== true) throw new Error('manual webui update must disable the apply button'); +if(state.btnForceUpdate.style.display !== 'none') throw new Error('manual webui update must hide the force button'); +if(state.btnForceUpdate.disabled !== true) throw new Error('manual webui update must disable the force button'); +if(state.btnClearUpdateLock.style.display !== 'none') throw new Error('manual webui update must hide the clear lock button'); +if(state.btnClearUpdateLock.disabled !== true) throw new Error('manual webui update must disable the clear lock button'); +if(state.updateBanner.classList.added !== true) throw new Error('manual update must show the banner'); +""".strip() + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_settings_manual_webui_update_includes_pull_guidance(self): + ui_src = read('static/ui.js') + panels_src = read('static/panels.js') + format_fn = extract_js_function(ui_src, '_formatUpdateTargetStatus') + instruction_fn = extract_js_function(ui_src, '_formatManualUpdateInstruction') + error_fn = extract_js_function(ui_src, '_formatUpdateCheckError') + check_fn = extract_js_function(panels_src, 'checkUpdatesNow') + script = f""" +const state = {{ + btnCheckUpdatesNow: {{ disabled: false }}, + checkUpdatesLabel: {{ textContent: '' }}, + checkUpdatesSpinner: {{ style: {{ display: 'none' }} }}, + checkUpdatesStatus: {{ textContent: '', style: {{ color: '' }} }}, +}}; +let apiData = {{ + webui: {{ + no_git: true, + manual_update: true, + behind: 1, + release_based: true, + current_version: 'v0.51.833', + latest_version: 'v0.51.913', + }}, + agent: null, +}}; +function $(id) {{ return state[id] || null; }} +function t(key) {{ + const values = {{ + settings_checking: 'Checking', + settings_check_now: 'Check now', + settings_updates_available: '{{count}} update(s) available', + settings_update_no_git: 'Cannot check for updates', + settings_up_to_date: 'Up to date', + settings_update_check_failed: 'Check failed', + }}; + return values[key] || key; +}} +async function api() {{ return apiData; }} +function _showUpdateBanner() {{}} +{format_fn} +{instruction_fn} +{error_fn} +{check_fn} +(async () => {{ + await checkUpdatesNow(); + if(state.checkUpdatesStatus.textContent.indexOf('docker pull ghcr.io/nesquena/hermes-webui:latest') === -1) throw new Error('settings manual update must render pull guidance: '+state.checkUpdatesStatus.textContent); + if(state.checkUpdatesStatus.style.color !== 'var(--accent)') throw new Error('manual update should stay in available state'); + apiData = {{ webui: {{ no_git: true, behind: 1 }}, agent: null }}; + state.checkUpdatesStatus.textContent = ''; + await checkUpdatesNow(); + if(state.checkUpdatesStatus.textContent.indexOf('docker pull') !== -1) throw new Error('plain no-git must not show manual guidance'); + if(state.checkUpdatesStatus.textContent !== 'Cannot check for updates') throw new Error('plain no-git should keep cannot-check status: '+state.checkUpdatesStatus.textContent); +}})().catch(err => {{ console.error(err.message); process.exit(1); }}); +""".strip() + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) # ── static/index.html ───────────────────────────────────────────────────────── diff --git a/tests/test_updates.py b/tests/test_updates.py index 6ab40331f..88adba1cd 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -1,4 +1,5 @@ """Tests for self-update diagnostics (api/updates.py).""" +import json import os import time from unittest.mock import MagicMock, patch @@ -74,6 +75,74 @@ def test_check_repo_redacts_credentialed_fetch_failure(tmp_path): assert 'Authentication failed' in info['error'] +def test_check_repo_reports_manual_update_for_baked_webui_version(tmp_path, monkeypatch): + """Docker WebUI installs should banner a manual update when GitHub tags are newer.""" + + class FakeResponse: + def __init__(self, body): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return self._body + + payload = [ + {'name': 'v0.51.833', 'commit': {'sha': 'current-sha'}}, + {'name': 'v0.51.914-rc1', 'commit': {'sha': 'prerelease-sha'}}, + {'name': 'v0.51.914', 'commit': {'sha': 'stable-sha'}}, + ] + seen = {} + + def fake_urlopen(request, timeout=0): + seen['url'] = request.full_url + seen['timeout'] = timeout + return FakeResponse(json.dumps(payload).encode('utf-8')) + + monkeypatch.setattr(updates.urllib.request, 'urlopen', fake_urlopen) + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.51.833') + + info = updates._check_repo(tmp_path, 'webui') + + assert info['name'] == 'webui' + assert info['no_git'] is True + assert info['manual_update'] is True + assert info['release_based'] is True + assert info['behind'] == 1 + assert info['current_version'] == 'v0.51.833' + assert info['latest_version'] == 'v0.51.914' + assert info['current_sha'] == 'current-sha' + assert info['latest_sha'] == 'stable-sha' + assert info['compare_url'] == ( + 'https://github.com/nesquena/hermes-webui/compare/current-sha...stable-sha' + ) + assert seen['url'] == 'https://api.github.com/repos/nesquena/hermes-webui/tags?per_page=100' + assert seen['timeout'] == 3.0 + + +def test_check_repo_webui_no_git_falls_back_to_old_payload_on_tags_failure(tmp_path, monkeypatch): + """If GitHub tags cannot be read, the Docker path must stay on can't-check.""" + + monkeypatch.setattr(updates.urllib.request, 'urlopen', lambda *args, **kwargs: (_ for _ in ()).throw(updates.urllib.error.URLError('boom'))) + monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.51.833') + + info = updates._check_repo(tmp_path, 'webui') + + assert info == {'name': 'webui', 'behind': None, 'no_git': True} + + +def test_check_repo_no_git_agent_stays_cant_check(tmp_path): + """Agent no-git installs must keep the legacy can't-check response.""" + + info = updates._check_repo(tmp_path, 'agent') + + assert info == {'name': 'agent', 'behind': None, 'no_git': True} + + def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path): """If release tags cannot be read, behind is unknown rather than zero.""" (tmp_path / '.git').mkdir()