From 69570ca77c2ab4bb71823c97782b851b1ba700ce Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Apr 2026 00:26:55 -0700 Subject: [PATCH] =?UTF-8?q?release:=20v0.50.102=E2=80=93v0.50.108=20batch?= =?UTF-8?q?=20(code=20blocks,=20utf-8,=20image=20URLs,=20deletion=20warnin?= =?UTF-8?q?g,=20PermissionError,=20Docker=20docs,=20kimi-k2.5)=20(#755)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Batch release: v0.50.102 – v0.50.108 Seven self-built PRs reviewed and approved by @nesquena, now consolidated into a single release branch. ### Included fixes | Version | PR | What it fixes | |---|---|---| | v0.50.102 | #746 | Code blocks lose newlines when not preceded by blank line (fixes #745) | | v0.50.103 | #743 | `encoding='utf-8'` on `write_text()` in `api/profiles.py` — Windows `.env` detection (fixes #741) | | v0.50.104 | #735 | Agent `MEDIA:localhost:*` image URLs rewritten to `document.baseURI` — remote users get working images (fixes #642) | | v0.50.105 | #736 | Profile deletion warning strengthened: "permanently deleted, cannot be undone" across all 6 locales (fixes #637) | | v0.50.106 | #738 | Catch `PermissionError` in `_signing_key()` — three-container Docker UID mismatch no longer crashes all HTTP requests | | v0.50.107 | #737 | Docs: three-container UID/GID alignment guide in README + `HERMES_UID`/`HERMES_GID` forwarded in compose (fixes #645) | | v0.50.108 | #742 | Add `kimi-k2.5` to Kimi/Moonshot provider model list (fixes #740) | ### Testing - **pytest**: 1510 passed, 1 warning (1 pre-existing unrelated failure excluded) - **QA harness**: 20/20 passed (`~/WebUI/scripts/run-browser-tests.sh`) - **Browser**: layout, slash autocomplete width, edit button, image URL rewrite, profile deletion dialog all verified All PRs reviewed and approved by @nesquena. Ready to merge and tag **v0.50.108**. --- CHANGELOG.md | 35 ++++++++++++++- README.md | 69 ++++++++++++++++++++++++++++++ api/auth.py | 8 ++-- api/config.py | 1 + api/profiles.py | 4 +- docker-compose.three-container.yml | 10 +++++ static/i18n.js | 10 +++-- static/ui.js | 15 +++++-- 8 files changed, 138 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac8768..9085c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,42 @@ # Hermes Web UI -- Changelog +## [v0.50.108] — 2026-04-20 + +### Fixed +- **Kimi K2.5 added to Kimi/Moonshot provider model list** — `kimi-k2.5` was present in `hermes_cli` but missing from the WebUI's `api/config.py` kimi-coding provider, making it unavailable in the model selector. (Fixes #740) + +## [v0.50.107] — 2026-04-20 + +### Added +- **Three-container UID/GID alignment guide in README** — new subsection explains why UIDs must match across containers sharing a bind-mounted volume, documents the variable name asymmetry (`HERMES_UID`/`HERMES_GID` for the agent image vs `WANTED_UID`/`WANTED_GID` for the WebUI image), gives the recommended `.env` setup for standard Linux and NAS/Unraid deployments, provides the one-time `chown` fix for existing installs, and notes that the dashboard volume must be read-write. (Fixes #645) + +### Fixed +- **`HERMES_UID`/`HERMES_GID` forwarded to agent and dashboard containers** — `docker-compose.three-container.yml` now declares `HERMES_UID=${HERMES_UID:-10000}` and `HERMES_GID=${HERMES_GID:-10000}` in the environment blocks for `hermes-agent` and `hermes-dashboard`, making the documented `.env` recipe functional. + +## [v0.50.106] — 2026-04-20 + +### Fixed +- **`PermissionError` in auth signing key no longer crashes every HTTP request** — `key_file.exists()` in `api/auth.py`'s `_signing_key()` was called outside the try/except block. In three-container bind-mount setups where the agent container initialises the state directory under a different UID, `pathlib.Path.exists()` raises `PermissionError`, which escaped up through `is_auth_enabled()` → `check_auth()` and crashed every HTTP request with HTTP 500. The `exists()` call is now inside the try block so `PermissionError` is caught and falls back to an in-memory key. (PR #625) + +## [v0.50.105] — 2026-04-20 + +### Fixed +- **Profile deletion warning now leads with destructive impact** — the confirmation dialog now reads: "All sessions, config, skills, and memory for this profile will be permanently deleted. This cannot be undone." Updated across all 6 supported locales. (Fixes #637) + +## [v0.50.104] — 2026-04-20 + +### Fixed +- **Agent image URLs rewritten to actual server base** — when an agent emits a `MEDIA:http://localhost:8787/...` URL, the WebUI now rewrites the `localhost`/`127.0.0.1` host to the page's `document.baseURI` before inserting it as an ``. Fixes broken images for remote users (VPN, Docker, deployed servers) and preserves subpath mounts (e.g. `/hermes/`). (Fixes #642) + +## [v0.50.103] — 2026-04-20 + +### Fixed +- **Windows `.env` encoding fix** — `write_text()` calls in `api/profiles.py` were missing `encoding='utf-8'`, causing failures on Windows systems with non-UTF-8 locale encodings. All file I/O in `api/` now explicitly specifies `encoding='utf-8'`. (Fixes #741) + ## [v0.50.102] — 2026-04-20 ### Fixed -- **Code blocks no longer lose newlines when not preceded by a blank line** — `renderMd()` in `static/ui.js` now stashes `
` blocks (including language-labelled blocks with `
` wrappers), mermaid diagrams, and katex blocks before the paragraph-splitting pass, then restores them. Previously, if a fenced code block was not separated from surrounding text by a double newline, all `\n` inside it were replaced with `
`, causing Prism.js to collapse the entire block to one line and misidentify everything after a `//` comment as a comment. (Fixes #745, reported by @qqxpee) +- **Code blocks no longer lose newlines when not preceded by a blank line** — `renderMd()` now stashes `
` blocks (including language-labelled wrappers), mermaid diagrams, and katex blocks before the paragraph-splitting pass, then restores them. Previously, if a fenced code block was not separated from surrounding text by a blank line, all `\n` inside it were replaced with `
`, collapsing the entire block to one line. (Fixes #745) ## [v0.50.101] — 2026-04-20 diff --git a/README.md b/README.md index 6d2be90..08b23c7 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,75 @@ first boot. Subsequent restarts reuse the installed packages. See `docker-compose.two-container.yml` for the full configuration. +### Running alongside hermes-dashboard (three-container setup) + +To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a +shared volume, use the three-container Compose file: + +```bash +docker compose -f docker-compose.three-container.yml up -d +``` + +This brings up: +- **`hermes-agent`** — gateway API on port 8642 +- **`hermes-dashboard`** — monitoring UI on port 9119 +- **`hermes-webui`** — browser chat interface on port 8787 + +All three services share the same `hermes-home` named volume so config, +sessions, skills, and memory are consistent across all surfaces. + +#### Why UIDs must match + +The `hermes-home` volume is a bind-mount in practice — all three containers +write to the same filesystem tree under `~/.hermes`. If the containers run +as different UIDs, whichever container creates a file first becomes its +owner, and the others hit `PermissionError` on subsequent writes. + +The fix is to make all containers run as **your host user's UID and GID**. + +#### Variable name asymmetry + +> ⚠️ **The two image families use different environment variable names** for +> the UID/GID setting: +> +> | Image | Variable | +> |---|---| +> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` | +> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` | +> +> You must set **both pairs** when using a `.env` file. + +#### Recommended setup + +For a standard Linux user (UID ≥ 1000): + +```bash +# Create a .env file with your host UID/GID +echo "UID=$(id -u)" >> .env +echo "GID=$(id -g)" >> .env +# hermes-agent / hermes-dashboard +echo "HERMES_UID=$(id -u)" >> .env +echo "HERMES_GID=$(id -g)" >> .env +``` + +For NAS/Unraid deployments where a fixed service account is preferred, use +`10000:10000` (or your NAS service UID) instead of `$(id -u)`. + +If you get `PermissionError` on an **existing** `~/.hermes` directory, run +the one-time ownership fix: + +```bash +chown -R $(id -u):$(id -g) ~/.hermes +``` + +#### Volume mount mode + +The dashboard container needs **read-write** access to the shared volume +(it writes session logs and dashboard state). Do **not** add `:ro` to the +`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry. + +See `docker-compose.three-container.yml` for the full reference configuration. + --- ## What start.sh discovers automatically diff --git a/api/auth.py b/api/auth.py index 53a43b2..15e419d 100644 --- a/api/auth.py +++ b/api/auth.py @@ -51,13 +51,13 @@ def _record_login_attempt(ip: str) -> None: def _signing_key(): """Return a random signing key, generating and persisting one on first call.""" key_file = STATE_DIR / '.signing_key' - if key_file.exists(): - try: + try: + if key_file.exists(): raw = key_file.read_bytes() if len(raw) >= 32: return raw[:32] - except Exception: - logger.debug("Failed to read signing key from file, generating new key") + except Exception: + logger.debug("Failed to read or access signing key file, using in-memory key") # Generate a new random key key = secrets.token_bytes(32) try: diff --git a/api/config.py b/api/config.py index 464e3dd..b91996e 100644 --- a/api/config.py +++ b/api/config.py @@ -533,6 +533,7 @@ _PROVIDER_MODELS = { {"id": "moonshot-v1-32k", "label": "Moonshot v1 32k"}, {"id": "moonshot-v1-128k", "label": "Moonshot v1 128k"}, {"id": "kimi-latest", "label": "Kimi Latest"}, + {"id": "kimi-k2.5", "label": "Kimi K2.5"}, ], "minimax": [ {"id": "MiniMax-M2.7", "label": "MiniMax M2.7"}, diff --git a/api/profiles.py b/api/profiles.py index bc40414..26b0864 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -208,7 +208,7 @@ def switch_profile(name: str) -> dict: # Write sticky default for CLI consistency try: ap_file = _DEFAULT_HERMES_HOME / 'active_profile' - ap_file.write_text(name if name != 'default' else '') + ap_file.write_text(name if name != 'default' else '', encoding='utf-8') except Exception: logger.debug("Failed to write active profile file") @@ -357,7 +357,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: if api_key: model_section['api_key'] = api_key cfg['model'] = model_section - config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True)) + config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') def create_profile_api(name: str, clone_from: str = None, diff --git a/docker-compose.three-container.yml b/docker-compose.three-container.yml index 34e1aed..8afa3a3 100644 --- a/docker-compose.three-container.yml +++ b/docker-compose.three-container.yml @@ -28,6 +28,8 @@ services: - hermes-agent-src:/opt/hermes environment: - HERMES_HOME=/root/.hermes + - HERMES_UID=${HERMES_UID:-10000} + - HERMES_GID=${HERMES_GID:-10000} restart: unless-stopped deploy: resources: @@ -47,6 +49,8 @@ services: - hermes-home:/root/.hermes environment: - HERMES_HOME=/root/.hermes + - HERMES_UID=${HERMES_UID:-10000} + - HERMES_GID=${HERMES_GID:-10000} # Dashboard connects to the gateway for health/session data - GATEWAY_HEALTH_URL=http://hermes-agent:8642 depends_on: @@ -90,6 +94,12 @@ services: # echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env - WANTED_UID=${UID:-1000} - WANTED_GID=${GID:-1000} + # NOTE: When using bind-mount volumes shared across containers, ALL containers + # that write to the same host directory must run as the same UID/GID. + # If hermes-agent initialises the state dir as root (UID 0), hermes-webui + # will get a PermissionError accessing those paths — including a crash on every + # HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID + # to match the agent container's UID, or use a named Docker volume (preferred). # Optional: set a password for remote access # - HERMES_WEBUI_PASSWORD=your-secret-password restart: unless-stopped diff --git a/static/i18n.js b/static/i18n.js index f1e2bc6..898588b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -464,7 +464,7 @@ const LOCALES = { profile_base_url_rule: 'Base URL must start with http:// or https://', profile_created: (name) => `Profile created: ${name}`, profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, - profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', + profile_delete_confirm_message: 'All sessions, config, skills, and memory for this profile will be permanently deleted. This cannot be undone.', profile_deleted: (name) => `Profile deleted: ${name}`, active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, @@ -914,7 +914,7 @@ const LOCALES = { profile_base_url_rule: 'Базовый URL должен начинаться с http:// или https://', profile_created: (name) => `Профиль создан: ${name}`, profile_delete_confirm_title: (name) => `Удалить профиль «${name}»?`, - profile_delete_confirm_message: 'Это удалит всю конфигурацию, навыки, память и сеансы этого профиля.', + profile_delete_confirm_message: 'Все сеансы, конфигурация, навыки и память этого профиля будут удалены безвозвратно. Это действие невозможно отменить.', profile_deleted: (name) => `Профиль удалён: ${name}`, active_conversation_none: 'Активная беседа не выбрана.', active_conversation_meta: (title, count) => { @@ -1348,7 +1348,7 @@ const LOCALES = { profile_base_url_rule: 'Base URL must start with http:// or https://', profile_created: (name) => `Profile created: ${name}`, profile_delete_confirm_title: (name) => `Delete profile "${name}"?`, - profile_delete_confirm_message: 'This removes all config, skills, memory, and sessions for this profile.', + profile_delete_confirm_message: 'Todas las sesiones, configuración, habilidades y memoria de este perfil se eliminarán de forma permanente. Esta acción no se puede deshacer.', profile_deleted: (name) => `Profile deleted: ${name}`, active_conversation_none: 'No active conversation selected.', active_conversation_meta: (title, count) => `${title} · ${count} message${count === 1 ? '' : 's'}`, @@ -1581,6 +1581,7 @@ const LOCALES = { onboarding_password_will_replace: 'Wird ersetzt', onboarding_password_keep_existing: 'Aktuelles Passwort beibehalten', onboarding_password_remains_disabled: 'Bleibt deaktiviert', + profile_delete_confirm_message: 'Alle Sitzungen, Konfigurationen, Fähigkeiten und Erinnerungen dieses Profils werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.', }, zh: { @@ -1992,7 +1993,7 @@ const LOCALES = { profile_base_url_rule: 'Base URL 必须以 http:// 或 https:// 开头', profile_created: (name) => `配置档已创建:${name}`, profile_delete_confirm_title: (name) => `删除配置档“${name}”?`, - profile_delete_confirm_message: '这将删除该配置档的所有配置、技能、记忆和会话。', + profile_delete_confirm_message: '该配置档的所有会话、配置、技能和记忆将被永久删除。此操作无法撤销。', profile_deleted: (name) => `配置档已删除:${name}`, active_conversation_none: '当前未选择活动会话。', active_conversation_meta: (title, count) => `${title} · ${count} 条消息`, @@ -2235,6 +2236,7 @@ const LOCALES = { // ui.js workspace_desc: '\u8acb\u9078\u64c7\u5de5\u4f5c\u5340\uff0c\u6216\u8f09\u5165\u65b0\u540d\u7a31\u5beb\u4e00\u500b', tab_profiles: '\u914d\u7f6e', + profile_delete_confirm_message: '\u6b64\u914d\u7f6e\u6a94\u7684\u6240\u6709\u6703\u8a71\u3001\u8a2d\u5b9a\u3001\u6280\u80fd\u548c\u8a18\u61b6\u5c07\u88ab\u6c38\u4e45\u522a\u9664\u3002\u6b64\u64cd\u4f5c\u7121\u6cd5\u64a4\u92b7\u3002', }, }; diff --git a/static/ui.js b/static/ui.js index 69c9434..e33123f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -663,10 +663,19 @@ function renderMd(raw){ const ref=media_stash[+i]; // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ - if(_IMAGE_EXTS.test(ref.split('?')[0])){ - return `image`; + // Rewrite localhost/127.0.0.1 to the actual server base URL so remote + // users (VPN, Docker, deployed) can load agent-generated images (#642). + // Strip the trailing slash from document.baseURI so the URL's own path + // joins cleanly — this preserves any subpath mount (e.g. /hermes/). + let src=ref; + if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){ + const base=document.baseURI.replace(/\/$/,''); + src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base); } - return `${esc(ref)}`; + if(_IMAGE_EXTS.test(src.split('?')[0])){ + return `image`; + } + return `${esc(src)}`; } // Local file path const apiUrl='api/media?path='+encodeURIComponent(ref);