From bdfba45247ed2b742d6f452353e6dbcdaaf8e9e6 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sat, 30 May 2026 13:51:52 -0600 Subject: [PATCH 01/11] fix(gateway): stop system tips from auto-uploading local files --- gateway/platforms/base.py | 17 +++++++++------ hermes_cli/tips.py | 7 +++--- tests/gateway/test_ephemeral_reply.py | 31 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index e1b677f12a1..6dbca9c7bc6 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -3736,6 +3736,7 @@ class BasePlatformAdapter(ABC): # Call the handler (this can take a while with tool calls) response = await self._message_handler(event) + is_ephemeral_response = isinstance(response, EphemeralReply) # Slash-command handlers may return an EphemeralReply sentinel to # request that their reply message auto-delete after a TTL (used @@ -3793,12 +3794,16 @@ class BasePlatformAdapter(ABC): if images: logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response)) - # Auto-detect bare local file paths for native media delivery - # (helps small models that don't use MEDIA: syntax) - local_files, text_content = self.extract_local_files(text_content) - local_files = self.filter_local_delivery_paths(local_files) - if local_files: - logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files)) + local_files = [] + if not is_ephemeral_response: + # Auto-detect bare local file paths for native media delivery + # (helps small models that don't use MEDIA: syntax). Skip + # system/command notices so config paths stay visible text + # instead of becoming native uploads. + local_files, text_content = self.extract_local_files(text_content) + local_files = self.filter_local_delivery_paths(local_files) + if local_files: + logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files)) # Auto-TTS: if voice message, generate audio FIRST (before sending text) # Gated via ``_should_auto_tts_for_chat``: fires when the chat has diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index feebe4310a0..25324a75628 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -215,7 +215,7 @@ TIPS = [ # --- Context & Compression --- "Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.", "The status bar turns yellow, then orange, then red as context fills up.", - "SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.", + "SOUL.md is the agent's primary identity file — customize it to shape behavior.", "Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).", "Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.", "Context files are capped at 20,000 characters with smart head/tail truncation.", @@ -273,7 +273,7 @@ TIPS = [ "Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.", "prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.", "SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.", - "SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.", + "SOUL.md is auto-seeded with a default personality on first run. Edit it to customize.", "/compress allocates 60-70% of the summary budget to your topic and aggressively trims the rest.", "On second+ compression, the compressor updates the previous summary instead of starting from scratch.", "Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.", @@ -430,7 +430,7 @@ TIPS = [ 'hermes -z "" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.', 'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.', 'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.', - 'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.', + 'hermes chat --ignore-user-config skips the active user config — reproducible bug reports and CI runs.', "hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.", 'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.', 'hermes sessions rename "new title" renames any past session; hermes sessions delete removes one.', @@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str: """ return random.choice(TIPS) - diff --git a/tests/gateway/test_ephemeral_reply.py b/tests/gateway/test_ephemeral_reply.py index 41565e163b0..61b70748e16 100644 --- a/tests/gateway/test_ephemeral_reply.py +++ b/tests/gateway/test_ephemeral_reply.py @@ -268,6 +268,37 @@ async def test_process_message_unwraps_ephemeral_before_send(): assert ("42", "sent-1") in adapter.deleted +@pytest.mark.asyncio +async def test_process_message_ephemeral_reply_does_not_auto_upload_bare_paths(tmp_path): + """Tips/system notices may mention local paths; they must remain text.""" + adapter = _delete_adapter() + adapter._send_with_retry = AsyncMock( + return_value=SendResult(success=True, message_id="sent-1") + ) + adapter.send_document = AsyncMock( + return_value=SendResult(success=True, message_id="doc-1") + ) + config_path = tmp_path / "config.yaml" + config_path.write_text("model:\n provider: test\n", encoding="utf-8") + reply_text = f"Tip: hermes chat --ignore-user-config skips {config_path}" + + async def _handler(evt): + return EphemeralReply(reply_text, ttl_seconds=0) + + adapter.set_message_handler(_handler) + + event = _make_event(text="/new") + session_key = "agent:main:telegram:private:42" + with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object( + adapter, "_keep_typing", new=AsyncMock() + ): + await adapter._process_message_background(event, session_key) + + adapter._send_with_retry.assert_called_once() + assert adapter._send_with_retry.call_args.kwargs["content"] == reply_text + adapter.send_document.assert_not_awaited() + + @pytest.mark.asyncio async def test_process_message_incapable_platform_does_not_schedule_delete(): adapter = _no_delete_adapter() From 4ec0adebe834c4a3a3838e83f47c7fadeb62380e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 30 May 2026 14:40:59 -0700 Subject: [PATCH 02/11] fix(gateway): denylist config.yaml for media delivery (belt-and-suspenders) Defense-in-depth on top of the EphemeralReply gate: even if a config.yaml path reaches response text via some other path, it can never be delivered as a native attachment. Matches existing protection for .env, auth.json, and credentials/. Co-authored-by: JezzaHehn --- gateway/platforms/base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 6dbca9c7bc6..26a6274eef6 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -954,11 +954,13 @@ def _media_delivery_denied_paths() -> List[Path]: home = Path(os.path.expanduser("~")) for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS: denied.append(home / sub) - # The Hermes home itself contains credentials (auth.json, .env) — only the - # cache subdirectories under it are explicitly allowlisted above. + # The Hermes home itself contains credentials (auth.json, .env) and + # configuration (config.yaml) — only the cache subdirectories under it + # are explicitly allowlisted above. denied.append(_HERMES_HOME / ".env") denied.append(_HERMES_HOME / "auth.json") denied.append(_HERMES_HOME / "credentials") + denied.append(_HERMES_HOME / "config.yaml") return denied From ec67def5bf4e95d9dd1d3ab85c46179f3f97613f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 30 May 2026 18:59:05 -0700 Subject: [PATCH 03/11] fix(install): refresh stale uv so installs actually get FTS5 Python (#35541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer's ensure_fts5() handled a no-FTS5 Python by running 'uv python install --reinstall', but WHICH Python builds a uv can install is baked into the uv binary's download manifest. A stale uv (e.g. 'pip install uv==0.7.20', which predates python-build-standalone #694) only knows about pre-FTS5 builds, so --reinstall just pulls the same FTS5-less interpreter — a no-op for FTS5. Result: 'Could not obtain an FTS5-capable Python' and a broken session search even on the supported installer path. ensure_fts5() now escalates uv itself: reinstall with current uv -> 'uv self update' + reinstall (stale standalone uv) -> install a fresh standalone uv into a temp dir and reinstall with that (externally-managed uv that can't self-update, the reported case). Pythons live in uv's shared store, so the fresh uv's --reinstall overwrites the stale interpreter in place and the installer's later 'uv python find' resolves to the FTS5-capable build. Verified against the reporter's exact repro (ubuntu:24.04 + pip install uv==0.7.20): Python 3.11.13 (no FTS5) -> 3.11.15 (FTS5). --- scripts/install.sh | 85 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index bf96b93c6d0..92cfc4ee2d8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -505,35 +505,88 @@ except Exception: PY } +# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH. +# Returns 0 if the resulting interpreter ships FTS5. +_reinstall_python_with_fts5() { + local uv_bin="$1" + "$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1 + PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)" + PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" + [ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH" +} + +_warn_no_fts5() { + # Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.). + # Install proceeds — Hermes degrades gracefully and disables only full-text + # session search — but warn so it isn't a silent gap. + log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but" + log_warn "full-text session search will be disabled until FTS5 is present." +} + # Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python # distributions only gained FTS5 in mid-2025 (python-build-standalone #694), -# so a stale interpreter already in uv's store — which `uv python find` -# happily reuses — can lack it. When that happens, force a reinstall of the -# latest patch for $PYTHON_VERSION (which has FTS5) and re-resolve. This keeps -# the supported install path's session search working without bundling a -# second SQLite or asking the user to do anything. +# but WHICH builds a given uv can install is baked into the uv binary's +# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows +# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the +# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a +# no-op for FTS5. To actually fix everyone's install, we escalate uv itself: +# +# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under +# an already-current uv) +# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall — +# this is what fixes a stale standalone uv +# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a +# fresh standalone uv via the official installer into a temp dir and use +# THAT to reinstall — this fixes package-manager-managed stale uv +# +# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the +# stale interpreter in place and the installer's later `uv python find` resolves +# to it. Keeps session search working without bundling a second SQLite or asking +# the user to do anything. ensure_fts5() { [ -n "${PYTHON_PATH:-}" ] || return 0 if _python_has_fts5 "$PYTHON_PATH"; then return 0 fi + # Termux / non-uv installs have nothing to escalate. + [ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; } log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)." log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..." - if "$UV_CMD" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1; then - PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)" - PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)" + if _reinstall_python_with_fts5 "$UV_CMD"; then + log_success "FTS5 available ($PYTHON_FOUND_VERSION)" + return 0 fi - if [ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"; then - log_success "FTS5 available ($PYTHON_FOUND_VERSION)" - else - # Could not obtain an FTS5-capable interpreter (offline, pinned env, - # etc.). Install proceeds — Hermes degrades gracefully and disables - # only full-text session search — but warn so it isn't a silent gap. - log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but" - log_warn "full-text session search will be disabled until FTS5 is present." + # Still no FTS5 — the uv binary itself is too old to know about FTS5-capable + # Python builds. Try to update uv in place. + log_info "uv is too old to provide an FTS5-capable Python — updating uv..." + if "$UV_CMD" self update >/dev/null 2>&1; then + if _reinstall_python_with_fts5 "$UV_CMD"; then + log_success "FTS5 available ($PYTHON_FOUND_VERSION)" + return 0 + fi fi + + # `uv self update` is unavailable on externally-managed uv (pip/apt/brew), + # which is exactly the case the user hit (`pip install uv==0.7.20`). Install + # a fresh standalone uv into a temp dir and use it just for the reinstall. + log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..." + local _tmp_uv_dir _fresh_uv + _tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")" + mkdir -p "$_tmp_uv_dir" + if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \ + | env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then + _fresh_uv="$_tmp_uv_dir/uv" + if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then + log_success "FTS5 available ($PYTHON_FOUND_VERSION)" + rm -rf "$_tmp_uv_dir" + return 0 + fi + fi + rm -rf "$_tmp_uv_dir" + + _warn_no_fts5 } check_git() { From 355af2c20f495b97c22c9aeb4c227fb0ca010da7 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sat, 30 May 2026 11:13:30 -0600 Subject: [PATCH 04/11] fix(session): survive missing FTS5 runtimes --- hermes_state.py | 291 +++++++++++++++++++++++++------------ tests/test_hermes_state.py | 122 +++++++++++++++- 2 files changed, 322 insertions(+), 91 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 771ded9918f..5122c69b939 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -72,6 +72,15 @@ _last_init_error_lock = threading.Lock() _wal_fallback_warned_paths: set[str] = set() _wal_fallback_warned_lock = threading.Lock() +_FTS_TRIGGERS = ( + "messages_fts_insert", + "messages_fts_delete", + "messages_fts_update", + "messages_fts_trigram_insert", + "messages_fts_trigram_delete", + "messages_fts_trigram_update", +) + def _set_last_init_error(msg: Optional[str]) -> None: """Record (or clear) the most recent state.db init failure. @@ -381,6 +390,7 @@ class SessionDB: self._lock = threading.Lock() self._write_count = 0 self._fts_enabled = False + self._fts_unavailable_warned = False try: self._conn = sqlite3.connect( str(self.db_path), @@ -417,6 +427,111 @@ class SessionDB: # ── Core write helper ── + @staticmethod + def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool: + err = str(exc).lower() + return "no such module" in err and "fts5" in err + + def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None: + self._fts_enabled = False + if self._fts_unavailable_warned: + return + self._fts_unavailable_warned = True + logger.warning( + "SQLite FTS5 unavailable for %s; full-text session search " + "disabled. This usually means Hermes is running on an " + "unsupported install (e.g. a pip-installed or pip-managed " + "Python whose bundled SQLite lacks FTS5) rather than a " + "mainline install. Some features may be missing or behave " + "differently. Install the supported way: " + "https://hermes-agent.nousresearch.com (underlying error: %s)", + self.db_path, + exc, + ) + + def _sqlite_supports_fts5(self, cursor: sqlite3.Cursor) -> bool: + try: + cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)") + cursor.execute("DROP TABLE temp._hermes_fts5_probe") + return True + except sqlite3.OperationalError as exc: + if not self._is_fts5_unavailable_error(exc): + raise + self._warn_fts5_unavailable(exc) + return False + + @staticmethod + def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None: + for trigger in _FTS_TRIGGERS: + try: + cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}") + except sqlite3.OperationalError: + pass + + @staticmethod + def _fts_trigger_count(cursor: sqlite3.Cursor) -> int: + placeholders = ",".join("?" for _ in _FTS_TRIGGERS) + row = cursor.execute( + f"SELECT COUNT(*) FROM sqlite_master " + f"WHERE type = 'trigger' AND name IN ({placeholders})", + _FTS_TRIGGERS, + ).fetchone() + return int(row[0] if not isinstance(row, sqlite3.Row) else row[0]) + + @staticmethod + def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None: + for table_name in ("messages_fts", "messages_fts_trigram"): + cursor.execute(f"DELETE FROM {table_name}") + cursor.execute( + "INSERT INTO messages_fts(rowid, content) " + "SELECT id, " + "COALESCE(content, '') || ' ' || " + "COALESCE(tool_name, '') || ' ' || " + "COALESCE(tool_calls, '') " + "FROM messages" + ) + cursor.execute( + "INSERT INTO messages_fts_trigram(rowid, content) " + "SELECT id, " + "COALESCE(content, '') || ' ' || " + "COALESCE(tool_name, '') || ' ' || " + "COALESCE(tool_calls, '') " + "FROM messages" + ) + + def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[bool]: + try: + cursor.execute(f"SELECT * FROM {table_name} LIMIT 0") + return True + except sqlite3.OperationalError as exc: + if self._is_fts5_unavailable_error(exc): + self._warn_fts5_unavailable(exc) + return None + if "no such table" in str(exc).lower(): + return False + raise + + def _ensure_fts_schema( + self, + cursor: sqlite3.Cursor, + table_name: str, + ddl: str, + ) -> bool: + status = self._fts_table_probe(cursor, table_name) + if status is None: + return False + try: + # Run even when the virtual table exists so any dropped or missing + # triggers are recreated after a previous no-FTS5 runtime disabled + # them to keep message writes working. + cursor.executescript(ddl) + return True + except sqlite3.OperationalError as exc: + if not self._is_fts5_unavailable_error(exc): + raise + self._warn_fts5_unavailable(exc) + return False + def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T: """Execute a write transaction with BEGIN IMMEDIATE and jitter retry. @@ -629,6 +744,16 @@ class SessionDB: except sqlite3.OperationalError as exc: logger.debug("idx_messages_platform_msg_id create skipped: %s", exc) + fts5_available = self._sqlite_supports_fts5(cursor) + fts_migrations_complete = True + if not fts5_available: + # Existing FTS triggers can still fire on messages INSERT/UPDATE + # even though the current sqlite runtime cannot read the virtual + # tables they target. Drop only the triggers so core persistence + # continues; if a future runtime has FTS5, _ensure_fts_schema() + # recreates them. + self._drop_fts_triggers(cursor) + # ── Schema version bookkeeping ───────────────────────────────── # Bump to current so future data migrations (if any) can gate on # version. No version-gated column additions remain. @@ -650,17 +775,24 @@ class SessionDB: # virtual table + triggers are created unconditionally via # FTS_TRIGRAM_SQL below, but existing rows need a one-time # backfill into the FTS index. - try: - cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0") - _fts_trigram_exists = True - except sqlite3.OperationalError: - _fts_trigram_exists = False - if not _fts_trigram_exists: - cursor.executescript(FTS_TRIGRAM_SQL) - cursor.execute( - "INSERT INTO messages_fts_trigram(rowid, content) " - "SELECT id, content FROM messages WHERE content IS NOT NULL" + if fts5_available: + _fts_trigram_exists = self._fts_table_probe( + cursor, "messages_fts_trigram" ) + if _fts_trigram_exists is False: + if self._ensure_fts_schema( + cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL + ): + cursor.execute( + "INSERT INTO messages_fts_trigram(rowid, content) " + "SELECT id, content FROM messages WHERE content IS NOT NULL" + ) + else: + fts_migrations_complete = False + elif _fts_trigram_exists is None: + fts_migrations_complete = False + else: + fts_migrations_complete = False if current_version < 11: # v11: re-index FTS5 tables to cover tool_name + tool_calls and # switch from external-content to inline mode. Existing DBs have @@ -668,45 +800,50 @@ class SessionDB: # overwrite, so we drop them explicitly and let the post-migration # existence checks (below) recreate them from FTS_SQL / # FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751. - for _trig in ( - "messages_fts_insert", - "messages_fts_delete", - "messages_fts_update", - "messages_fts_trigram_insert", - "messages_fts_trigram_delete", - "messages_fts_trigram_update", - ): - try: - cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}") - except sqlite3.OperationalError: - pass - for _tbl in ("messages_fts", "messages_fts_trigram"): - try: - cursor.execute(f"DROP TABLE IF EXISTS {_tbl}") - except sqlite3.OperationalError: - pass - # Recreate virtual tables + triggers with the new inline-mode - # schema that indexes content || tool_name || tool_calls. - cursor.executescript(FTS_SQL) - cursor.executescript(FTS_TRIGRAM_SQL) - # Backfill both indexes from every existing messages row. - cursor.execute( - "INSERT INTO messages_fts(rowid, content) " - "SELECT id, " - "COALESCE(content, '') || ' ' || " - "COALESCE(tool_name, '') || ' ' || " - "COALESCE(tool_calls, '') " - "FROM messages" - ) - cursor.execute( - "INSERT INTO messages_fts_trigram(rowid, content) " - "SELECT id, " - "COALESCE(content, '') || ' ' || " - "COALESCE(tool_name, '') || ' ' || " - "COALESCE(tool_calls, '') " - "FROM messages" - ) - if current_version < SCHEMA_VERSION: + if fts5_available: + self._drop_fts_triggers(cursor) + for _tbl in ("messages_fts", "messages_fts_trigram"): + try: + cursor.execute(f"DROP TABLE IF EXISTS {_tbl}") + except sqlite3.OperationalError as exc: + if not self._is_fts5_unavailable_error(exc): + raise + self._warn_fts5_unavailable(exc) + fts5_available = False + fts_migrations_complete = False + break + + if fts5_available: + # Recreate virtual tables + triggers with the new inline-mode + # schema that indexes content || tool_name || tool_calls. + if ( + self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL) + and self._ensure_fts_schema( + cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL + ) + ): + # Backfill both indexes from every existing messages row. + cursor.execute( + "INSERT INTO messages_fts(rowid, content) " + "SELECT id, " + "COALESCE(content, '') || ' ' || " + "COALESCE(tool_name, '') || ' ' || " + "COALESCE(tool_calls, '') " + "FROM messages" + ) + cursor.execute( + "INSERT INTO messages_fts_trigram(rowid, content) " + "SELECT id, " + "COALESCE(content, '') || ' ' || " + "COALESCE(tool_name, '') || ' ' || " + "COALESCE(tool_calls, '') " + "FROM messages" + ) + else: + fts_migrations_complete = False + else: + fts_migrations_complete = False + if current_version < SCHEMA_VERSION and fts_migrations_complete: cursor.execute( "UPDATE schema_version SET version = ?", (SCHEMA_VERSION,), @@ -721,47 +858,22 @@ class SessionDB: except sqlite3.OperationalError: pass # Index already exists - # FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably) - try: - cursor.execute("SELECT * FROM messages_fts LIMIT 0") - self._fts_enabled = True - except sqlite3.OperationalError as exc: - if "no such table" not in str(exc).lower(): - raise - try: - cursor.executescript(FTS_SQL) - self._fts_enabled = True - except sqlite3.OperationalError as fts_exc: - err = str(fts_exc).lower() - if "fts5" not in err and "no such module" not in err: - raise - logger.warning( - "SQLite FTS5 unavailable for %s; full-text session search " - "disabled. This usually means Hermes is running on an " - "unsupported install (e.g. a pip-installed or pip-managed " - "Python whose bundled SQLite lacks FTS5) rather than a " - "mainline install. Some features may be missing or behave " - "differently. Install the supported way: " - "https://hermes-agent.nousresearch.com (underlying error: %s)", - self.db_path, - fts_exc, - ) + if fts5_available: + # FTS5 setup. Run the DDL even when the virtual table exists so + # CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from + # an earlier no-FTS5 runtime. + triggers_need_repair = self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS) + self._fts_enabled = self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL) - # Trigram FTS5 for CJK/substring search - try: - cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0") - except sqlite3.OperationalError as exc: - if "no such table" not in str(exc).lower(): - raise - try: - cursor.executescript(FTS_TRIGRAM_SQL) - except sqlite3.OperationalError as fts_exc: - err = str(fts_exc).lower() - if "fts5" not in err and "no such module" not in err: - raise - # Same FTS5-unavailable cause already warned about above for - # messages_fts; the trigram table is an additional CJK index, - # so just degrade silently here. CJK search falls back to LIKE. + # Trigram FTS5 for CJK/substring search. This is optional relative + # to the main FTS table; if it cannot be created, CJK search falls + # back to LIKE. + if self._fts_enabled: + trigram_enabled = self._ensure_fts_schema( + cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL + ) + if trigram_enabled and triggers_need_repair: + self._rebuild_fts_indexes(cursor) self._conn.commit() @@ -3560,4 +3672,3 @@ class SessionDB: (error[:500], session_id), ) self._execute_write(_do) - diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 8fec76aa6b9..99a8616e2e6 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -4,7 +4,7 @@ import sqlite3 import time import pytest -from hermes_state import SessionDB +from hermes_state import SCHEMA_SQL, SessionDB class _NoFtsCursor(sqlite3.Cursor): @@ -12,6 +12,8 @@ class _NoFtsCursor(sqlite3.Cursor): def execute(self, sql, parameters=()): probe = sql.strip() + if "USING fts5" in probe: + raise sqlite3.OperationalError("no such module: fts5") if probe in ( "SELECT * FROM messages_fts LIMIT 0", "SELECT * FROM messages_fts_trigram LIMIT 0", @@ -30,6 +32,24 @@ class _NoFtsConnection(sqlite3.Connection): return super().cursor(factory or _NoFtsCursor) +class _NoFtsExistingTableCursor(_NoFtsCursor): + """Simulate existing FTS virtual tables under a runtime without FTS5.""" + + def execute(self, sql, parameters=()): + probe = sql.strip() + if probe in ( + "SELECT * FROM messages_fts LIMIT 0", + "SELECT * FROM messages_fts_trigram LIMIT 0", + ): + raise sqlite3.OperationalError("no such module: fts5") + return super().execute(sql, parameters) + + +class _NoFtsExistingTableConnection(sqlite3.Connection): + def cursor(self, factory=None): + return super().cursor(factory or _NoFtsExistingTableCursor) + + @pytest.fixture() def db(tmp_path): """Create a SessionDB with a temp database file.""" @@ -210,6 +230,106 @@ class TestSessionLifecycle: finally: db.close() + def test_existing_fts_tables_do_not_break_without_fts5( + self, tmp_path, monkeypatch + ): + db_path = tmp_path / "state.db" + seeded = SessionDB(db_path=db_path) + try: + seeded.create_session(session_id="s1", source="cli") + seeded.append_message("s1", role="user", content="before runtime change") + finally: + seeded.close() + + real_connect = sqlite3.connect + + def connect_without_fts(*args, **kwargs): + kwargs["factory"] = _NoFtsExistingTableConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts) + + db = SessionDB(db_path=db_path) + try: + assert db._fts_enabled is False + assert db.get_session("s1") is not None + assert len(db.get_messages("s1")) == 1 + + # Existing FTS triggers must be disabled too; otherwise this write + # would try to insert into an unusable FTS virtual table. + db.append_message("s1", role="assistant", content="after runtime change") + messages = db.get_messages("s1") + assert len(messages) == 2 + assert messages[1]["content"] == "after runtime change" + finally: + db.close() + + def test_old_schema_without_fts5_does_not_crash(self, tmp_path, monkeypatch): + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(str(db_path)) + conn.executescript(SCHEMA_SQL) + conn.execute("DELETE FROM schema_version") + conn.execute("INSERT INTO schema_version (version) VALUES (?)", (9,)) + conn.commit() + conn.close() + + real_connect = sqlite3.connect + + def connect_without_fts(*args, **kwargs): + kwargs["factory"] = _NoFtsConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts) + + db = SessionDB(db_path=db_path) + try: + assert db._fts_enabled is False + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="legacy no fts") + assert db.get_messages("s1")[0]["content"] == "legacy no fts" + assert db.search_messages("legacy") == [] + + # Leave the FTS migration version in place so a future FTS-capable + # runtime can still rebuild and backfill the indexes. + row = db._conn.execute("SELECT version FROM schema_version").fetchone() + assert row["version"] == 9 + finally: + db.close() + + def test_fts_runtime_restores_triggers_after_no_fts_open( + self, tmp_path, monkeypatch + ): + db_path = tmp_path / "state.db" + seeded = SessionDB(db_path=db_path) + try: + seeded.create_session(session_id="s1", source="cli") + seeded.append_message("s1", role="user", content="first searchable") + finally: + seeded.close() + + real_connect = sqlite3.connect + + def connect_without_fts(*args, **kwargs): + kwargs["factory"] = _NoFtsExistingTableConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts) + no_fts = SessionDB(db_path=db_path) + try: + no_fts.append_message("s1", role="assistant", content="not indexed yet") + finally: + no_fts.close() + + monkeypatch.setattr("hermes_state.sqlite3.connect", real_connect) + restored = SessionDB(db_path=db_path) + try: + assert restored._fts_enabled is True + restored.append_message("s1", role="assistant", content="indexed again") + assert len(restored.search_messages("not indexed yet")) == 1 + assert len(restored.search_messages("indexed")) == 2 + finally: + restored.close() + # ========================================================================= # Message storage From cd067ab91ee4ab0f0628f6d6b385e7b89d0cb9b9 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 30 May 2026 22:27:14 -0500 Subject: [PATCH 05/11] fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer (#35512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer When the Node event loop blocks during a heavy render/tool-call burst, stdin stops being drained. Mode-1003 any-motion mouse reports pile up in the kernel buffer, get partially read, and arrive as text with the `\x1b[<` prefix AND coordinate digits chewed off across many partial reads. The existing fragment recovery (SGR_MOUSE_FRAGMENT_RE) only handles clean `button;col;row[Mm]` triples, so the degraded shards leak into the composer as typed text — the user can no longer type or exit until the stall clears. Captured leak (Windows Terminal, during tool calls): M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M 48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22M M18M99;26M32MM38M63;44M47MM1;51M M4M54M Add two recovery layers in parseTextWithSgrMouseFragments / the text-token path: - MOUSE_BURST_NOISE_RE: whole-text fast path. If a text token is drawn only from the mouse-leak alphabet (`[ ] < ; I M m`, digits, spaces) AND carries the structural signature of mouse coordinates (>=3 M/m terminators, a digit, and a `;`), swallow it wholesale. - MOUSE_BURST_RESIDUE_RE: swallows pure-noise residue in the gaps between and after recovered fragments, so a partially-recovered burst doesn't trail a chewed-up tail into the prompt. All three constraints together preserve real prose: `Mmm MMM mmm yummy` has no digit/`;`, `see 1;2;3M for details` has disqualifying letters, and `1234;56;78M9;10;11M` has only two terminators — none are swallowed. This is defense-in-depth: it stops the leak/lockout regardless of what blocks the loop. The underlying event-loop stall during streaming is a separate, still-open issue that needs live-turn instrumentation to root-cause. * fix(tui): check mouse-burst noise before fragment recovery; drop test cast Copilot review on #35512: - MOUSE_BURST_NOISE_RE was only evaluated when parseTextWithSgrMouseFragments returned null. A noise blob that contains any intact ` * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hermes-ink/src/ink/parse-keypress.test.ts | 31 +++++++++++ .../hermes-ink/src/ink/parse-keypress.ts | 52 ++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index cee7ab39ddc..2905c53a2ba 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -133,4 +133,35 @@ describe('fragmented SGR mouse recovery', () => { expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' }) }) + + it('swallows a fully degraded mouse-burst noise blob without leaking prompt text', () => { + // Captured from Windows Terminal during a heavy tool-call render: the event + // loop blocked past App's 50ms flush timer, so a long burst of SGR mouse + // reports (mode 1003 any-motion) arrived as text with prefixes AND + // too degraded for SGR_MOUSE_FRAGMENT_RE (1- and 2-param remnants, a + // stray focus-in `[I`), so without the whole-text noise fast path the entire + // blob types into the composer and locks the user out. + const blob = + 'M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22MM18M99;26M32MM38M63;44M47MM1;51M M4M54M' + const [events] = parseMultipleKeypresses(INITIAL_STATE, blob) + + expect(events).toEqual([]) + }) + + it('keeps plain prose that only contains scattered M and m letters', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'Mmm MMM mmm yummy') + + expect(key).toMatchObject({ kind: 'key', sequence: 'Mmm MMM mmm yummy' }) + }) + + it('swallows noise wholesale even when it contains intact recoverable fragments', () => { + // A noise blob can carry a few intact `\|(.*?)(?:\x07|\x1b\\)$/s const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ const SGR_MOUSE_FRAGMENT_RE = /(? cursor) { - parsed.push(parseKeypress(text.slice(cursor, first.index!))) + const gap = text.slice(cursor, first.index!) + // Skip pure mouse-leak residue between recovered fragments; only emit + // real text gaps as keypresses. + if (!MOUSE_BURST_RESIDUE_RE.test(gap)) { + parsed.push(parseKeypress(gap)) + } } for (const match of run) { @@ -690,7 +733,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { } if (cursor < text.length) { - parsed.push(parseKeypress(text.slice(cursor))) + const tail = text.slice(cursor) + // Swallow a pure mouse-leak residue tail (the head fragments recovered, but + // the burst trailed off into chewed-up shards). Emit only real trailing text. + if (!MOUSE_BURST_RESIDUE_RE.test(tail)) { + parsed.push(parseKeypress(tail)) + } } return parsed From b1d34cf6e28f3aa161ca9788eb7ff0c76bf8b7f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 30 May 2026 20:42:30 -0700 Subject: [PATCH 06/11] fix(tui): clamp bogus terminal dimensions (WSL 131072x1) (#35657) Some hosts (notably WSL) report a junk window size such as 131072 columns by 1 row. Both the Ink fork and our components only guard against 0/null/undefined/NaN (stdout.columns || 80), so a positive-but-absurd width sails through into createScreen(width*height), allocating tens to hundreds of MB per frame and tripping the TUI memory monitor's hard exit. Add clampStdoutDimensions(), installed in entry.tsx before ink.render: it patches process.stdout.columns/rows with clamping getters (cols 1-2000, rows 1-1000; out-of-range -> 80x24). One install point fixes the renderer, its resize handler, and every component read. Live resizes still propagate through the original descriptor, just clamped. --- .../src/__tests__/terminalDimensions.test.ts | 108 +++++++++++++ ui-tui/src/entry.tsx | 7 + ui-tui/src/lib/terminalDimensions.ts | 143 ++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 ui-tui/src/__tests__/terminalDimensions.test.ts create mode 100644 ui-tui/src/lib/terminalDimensions.ts diff --git a/ui-tui/src/__tests__/terminalDimensions.test.ts b/ui-tui/src/__tests__/terminalDimensions.test.ts new file mode 100644 index 00000000000..773c30f0b0d --- /dev/null +++ b/ui-tui/src/__tests__/terminalDimensions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' + +import { + clampStdoutDimensions, + DEFAULT_COLUMNS, + DEFAULT_ROWS, + MAX_COLUMNS, + MAX_ROWS, + sanitizeDimension, + sanitizeTerminalSize +} from '../lib/terminalDimensions.js' + +describe('sanitizeDimension', () => { + it('passes through an in-range value', () => { + expect(sanitizeDimension(120, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(120) + }) + + it('floors fractional values', () => { + expect(sanitizeDimension(80.9, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(80) + }) + + it('clamps an absurd width to the max, not the fallback', () => { + expect(sanitizeDimension(131072, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(MAX_COLUMNS) + }) + + it('falls back when value is zero', () => { + expect(sanitizeDimension(0, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + }) + + it('falls back when value is negative', () => { + expect(sanitizeDimension(-5, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + }) + + it('falls back on NaN / undefined / non-number', () => { + expect(sanitizeDimension(NaN, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + expect(sanitizeDimension(undefined, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + expect(sanitizeDimension('80', 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + expect(sanitizeDimension(Infinity, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS) + }) +}) + +describe('sanitizeTerminalSize', () => { + it('sanitizes the WSL 131072x1 report', () => { + // 131072 cols is absurd → clamp to max; 1 row is a valid (degenerate) TTY → keep. + expect(sanitizeTerminalSize(131072, 1)).toEqual({ columns: MAX_COLUMNS, rows: 1 }) + }) + + it('passes a normal terminal through unchanged', () => { + expect(sanitizeTerminalSize(120, 40)).toEqual({ columns: 120, rows: 40 }) + }) + + it('falls back when both dimensions are missing', () => { + expect(sanitizeTerminalSize(undefined, undefined)).toEqual({ + columns: DEFAULT_COLUMNS, + rows: DEFAULT_ROWS + }) + }) + + it('clamps an oversized height', () => { + expect(sanitizeTerminalSize(80, 99999)).toEqual({ columns: 80, rows: MAX_ROWS }) + }) +}) + +describe('clampStdoutDimensions', () => { + it('clamps a bogus columns getter on a live stream', () => { + let raw = 131072 + const stream: { columns?: number; rows?: number } = {} + Object.defineProperty(stream, 'columns', { configurable: true, get: () => raw }) + Object.defineProperty(stream, 'rows', { configurable: true, get: () => 1 }) + + clampStdoutDimensions(stream) + + expect(stream.columns).toBe(MAX_COLUMNS) + expect(stream.rows).toBe(1) + + // Live resize still propagates through the original getter, clamped. + raw = 100 + expect(stream.columns).toBe(100) + + raw = 0 + expect(stream.columns).toBe(DEFAULT_COLUMNS) + }) + + it('clamps a bogus plain-value columns property', () => { + const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 } + + clampStdoutDimensions(stream) + + expect(stream.columns).toBe(MAX_COLUMNS) + expect(stream.rows).toBe(24) + }) + + it('is idempotent', () => { + const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 } + + clampStdoutDimensions(stream) + clampStdoutDimensions(stream) + + expect(stream.columns).toBe(MAX_COLUMNS) + }) + + it('does not crash on a non-configurable property', () => { + const stream: { columns?: number; rows?: number } = {} + Object.defineProperty(stream, 'columns', { configurable: false, value: 131072 }) + + expect(() => clampStdoutDimensions(stream)).not.toThrow() + }) +}) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index effde40fef9..787f738f9f3 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -11,6 +11,7 @@ import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js' import { openExternalUrl } from './lib/openExternalUrl.js' +import { clampStdoutDimensions } from './lib/terminalDimensions.js' import { resetTerminalModes } from './lib/terminalModes.js' if (!process.stdin.isTTY) { @@ -18,6 +19,12 @@ if (!process.stdin.isTTY) { process.exit(0) } +// Some hosts (notably WSL) report bogus window sizes such as 131072x1. Clamp +// `process.stdout.columns`/`rows` at the source so the Ink renderer, its +// resize handler, and every component read see sane values. Must run before +// `ink.render` constructs the renderer. +clampStdoutDimensions() + // Start from a clean slate. If a previous TUI crashed or was kill -9'd, the // terminal tab can still have mouse/focus/paste modes enabled. resetTerminalModes() diff --git a/ui-tui/src/lib/terminalDimensions.ts b/ui-tui/src/lib/terminalDimensions.ts new file mode 100644 index 00000000000..e6fabcd15eb --- /dev/null +++ b/ui-tui/src/lib/terminalDimensions.ts @@ -0,0 +1,143 @@ +/** + * Sanitize terminal dimensions reported by the host. + * + * Some environments report bogus window sizes. The motivating case (WSL, + * reported by @northframe_17) is `columns=131072, rows=1` — a width that + * overflows any sane layout and a height of one row that makes the TUI + * unusable. Node's own `stdout.columns || 80` fallback only catches + * `0`/`NaN`/`undefined`, so a positive-but-absurd value sails straight into + * the Ink renderer, which then allocates a 131072-cell-wide screen buffer. + * + * We clamp each dimension independently to a sane range. Out-of-range or + * non-finite values fall back to the conventional 80x24 default rather than + * the raw garbage. + */ + +export const DEFAULT_COLUMNS = 80 +export const DEFAULT_ROWS = 24 + +// Upper bounds are generous (ultrawide multi-monitor terminals, tmux panes +// spanning huge displays) but well below the WSL garbage value. Anything +// beyond these is treated as a broken probe. +export const MAX_COLUMNS = 2000 +export const MAX_ROWS = 1000 +export const MIN_COLUMNS = 1 +export const MIN_ROWS = 1 + +/** + * Clamp a single reported dimension into `[min, max]`. + * + * Returns `fallback` when the value is non-finite or `<= 0` (the classic + * "no size yet" signal). A positive value above `max` is clamped to `max`, + * not replaced by the fallback — an oversized-but-finite report is more + * likely a real-but-large terminal than a missing one, and clamping keeps + * the layout sane either way. + */ +export function sanitizeDimension(value: unknown, min: number, max: number, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return fallback + } + + const rounded = Math.floor(value) + + if (rounded < min) { + return fallback + } + + if (rounded > max) { + return max + } + + return rounded +} + +export interface SanitizedTerminalSize { + columns: number + rows: number +} + +/** Sanitize a (columns, rows) pair using the TUI's bounds. */ +export function sanitizeTerminalSize(columns: unknown, rows: unknown): SanitizedTerminalSize { + return { + columns: sanitizeDimension(columns, MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS), + rows: sanitizeDimension(rows, MIN_ROWS, MAX_ROWS, DEFAULT_ROWS) + } +} + +interface ClampableStream { + columns?: number + rows?: number +} + +const PATCHED = Symbol.for('hermes.tui.clampedDimensions') + +/** + * Install clamping getters on `process.stdout` (or a provided stream) so every + * downstream reader — the Ink renderer's root layout, its `resize` handler, + * and our React components' `stdout.columns ?? 80` reads — sees sanitized + * values. Must run before `ink.render`. + * + * Idempotent: re-installing on an already-patched stream is a no-op. The raw + * values are read through the original property descriptor on each access, so + * live resizes still propagate (just clamped). + */ +export function clampStdoutDimensions(stream: ClampableStream = process.stdout): void { + const target = stream as ClampableStream & { [PATCHED]?: boolean } + + if (target[PATCHED]) { + return + } + + // Capture the original descriptors so we read the live host value on every + // access rather than freezing a single snapshot. + const colsDesc = findDescriptor(target, 'columns') + const rowsDesc = findDescriptor(target, 'rows') + + const readCols = () => (colsDesc ? readValue(target, colsDesc) : target.columns) + const readRows = () => (rowsDesc ? readValue(target, rowsDesc) : target.rows) + + try { + Object.defineProperty(target, 'columns', { + configurable: true, + enumerable: true, + get() { + return sanitizeDimension(readCols(), MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS) + } + }) + Object.defineProperty(target, 'rows', { + configurable: true, + enumerable: true, + get() { + return sanitizeDimension(readRows(), MIN_ROWS, MAX_ROWS, DEFAULT_ROWS) + } + }) + target[PATCHED] = true + } catch { + // Non-configurable property on an exotic stream — leave it alone rather + // than crashing startup. Components still have their own `?? 80` guard. + } +} + +function findDescriptor(obj: object, key: string): PropertyDescriptor | undefined { + let cur: object | null = obj + + while (cur) { + const desc = Object.getOwnPropertyDescriptor(cur, key) + + if (desc) { + return desc + } + + cur = Object.getPrototypeOf(cur) as object | null + } + + return undefined +} + +function readValue(target: object, desc: PropertyDescriptor): unknown { + if (desc.get) { + return desc.get.call(target) + } + + return desc.value +} From 9ed9af2f7d5c8db93da721b3c9efd00ed0e02cc0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 30 May 2026 20:42:37 -0700 Subject: [PATCH 07/11] fix(update): name new config options in migration prompt; skip prompt for pure version bumps (#35658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'hermes update' config-migration prompt printed only counts ('1 new config option available') then asked 'configure them now?' without ever saying what the options were. Users said no because they couldn't tell what they were agreeing to. For pure config-format version bumps (no new env/config keys) it still asked the question, where saying yes just bumped the version and looked like a no-op. - List each new env var / config key by name + description before prompting (cap at 8, then '… and N more'). The data was already available; we just threw it away and printed a count. - Pure version bump (no new options): apply the format migration non-interactively and print what happened, instead of asking a misleading yes/no. Reported by ScottFive and Tt2021. --- hermes_cli/main.py | 49 +++++++++++++++++- tests/hermes_cli/test_cmd_update.py | 77 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 27105c57052..1cb4bd3d6b8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9557,16 +9557,61 @@ def _cmd_update_impl(args, gateway_mode: bool): missing_config = get_missing_config_fields() current_ver, latest_ver = check_config_version() - needs_migration = missing_env or missing_config or current_ver < latest_ver + has_new_options = bool(missing_env or missing_config) + version_bump_only = ( + not has_new_options and current_ver < latest_ver + ) + needs_migration = has_new_options or current_ver < latest_ver - if needs_migration: + if version_bump_only: + # Nothing for the user to fill in — only the config format version + # changed (new defaults already merge in transparently). Asking + # "configure new options now?" here is misleading: saying yes just + # bumps the version and looks like a no-op (issue: ScottFive / + # Tt2021). Apply it silently and say what actually happened. print() + print( + f" ℹ Updating config format (v{current_ver} → v{latest_ver})…" + ) + try: + migrate_config(interactive=False, quiet=True) + print(" ✓ Config format updated (no new settings to configure)") + except Exception as _mig_err: + print(f" ⚠️ Config format update failed: {_mig_err}") + print(" Run 'hermes config migrate' to retry.") + elif needs_migration: + print() + # Show WHAT changed, not just a count, so the user can make an + # informed yes/no decision (previously the prompt named nothing). + def _print_items(items, label, key, fallback_key=None): + if not items: + return + print(f" {label}:") + shown = items[:8] + for it in shown: + if isinstance(it, dict): + name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?" + desc = (it.get("description") or "").strip() + else: + # Defensive: some callers/mocks pass bare name strings. + name = str(it) + desc = "" + if desc: + print(f" • {name} — {desc}") + else: + print(f" • {name}") + extra = len(items) - len(shown) + if extra > 0: + print(f" … and {extra} more") + if missing_env: print( f" ⚠️ {len(missing_env)} new required setting(s) need configuration" ) + _print_items(missing_env, "New settings", "name") if missing_config: print(f" ℹ️ {len(missing_config)} new config option(s) available") + _print_items(missing_config, "New options", "key") print() if assume_yes: diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index ed9033ffce2..0aeb8e4080c 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -281,6 +281,83 @@ class TestCmdUpdateBranchFallback: assert "API keys require manual entry" in captured.out +class TestCmdUpdateMigrationPrompt: + """The config-migration prompt names what changed and skips the prompt + entirely when only the config format version moved. + + Regression guard for the contentless-prompt report (ScottFive / Tt2021): + previously the prompt printed only counts ("1 new config option") and + asked "configure them now?" even for pure version bumps, where saying + yes looked like a no-op. + """ + + def test_version_bump_only_applies_silently_without_prompt( + self, mock_args, capsys + ): + """Only the version moved → apply non-interactively, never prompt.""" + with patch("shutil.which", return_value=None), patch( + "subprocess.run" + ) as mock_run, patch("builtins.input") as mock_input, patch( + "hermes_cli.config.get_missing_env_vars", return_value=[] + ), patch( + "hermes_cli.config.get_missing_config_fields", return_value=[] + ), patch( + "hermes_cli.config.check_config_version", return_value=(5, 24) + ), patch( + "hermes_cli.config.migrate_config", + return_value={"env_added": [], "config_added": [], "warnings": []}, + ) as mock_migrate: + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="1" + ) + + cmd_update(mock_args) + + mock_input.assert_not_called() + mock_migrate.assert_called_once_with(interactive=False, quiet=True) + out = capsys.readouterr().out + assert "Updating config format (v5 → v24)" in out + assert "no new settings to configure" in out + # The misleading question must NOT appear for a pure version bump. + assert "configure them now" not in out.lower() + + def test_new_options_are_listed_by_name_before_prompt( + self, mock_args, capsys + ): + """New env/config keys are printed by name so the user can decide.""" + env_items = [ + {"name": "FOO_API_KEY", "description": "Foo service API key"}, + ] + cfg_items = [ + {"key": "display.new_widget", "description": "New config option: display.new_widget"}, + ] + with patch("shutil.which", return_value=None), patch( + "subprocess.run" + ) as mock_run, patch("builtins.input", return_value="n"), patch( + "hermes_cli.config.get_missing_env_vars", return_value=env_items + ), patch( + "hermes_cli.config.get_missing_config_fields", return_value=cfg_items + ), patch( + "hermes_cli.config.check_config_version", return_value=(1, 24) + ), patch( + "hermes_cli.config.migrate_config", + return_value={"env_added": [], "config_added": [], "warnings": []}, + ), patch("hermes_cli.main.sys") as mock_sys: + mock_sys.stdin.isatty.return_value = True + mock_sys.stdout.isatty.return_value = True + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="1" + ) + + cmd_update(mock_args) + + out = capsys.readouterr().out + # Names, not just counts. + assert "FOO_API_KEY" in out + assert "Foo service API key" in out + assert "display.new_widget" in out + + class TestCmdUpdateProfileSkillSync: """cmd_update syncs bundled skills to all profiles, including the active one. From c2cbe2c97df442aba8d1d5ffad70f5f376c30ea1 Mon Sep 17 00:00:00 2001 From: BarnacleBoy Date: Mon, 25 May 2026 21:31:21 +0000 Subject: [PATCH 08/11] fix: remove Discord mention redaction from secret scrubber --- agent/redact.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/agent/redact.py b/agent/redact.py index 5de714a5f99..6c713cb4e41 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -150,10 +150,6 @@ _JWT_RE = re.compile( r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature ) -# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678> -# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts. -_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>") - # E.164 phone numbers: +, 7-15 digits # Negative lookahead prevents matching hex strings or identifiers _SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])") @@ -419,10 +415,6 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F if "&" in text and "=" in text: text = _redact_form_body(text) - # Discord user/role mentions (<@snowflake_id>) - if "<@" in text: - text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text) - # E.164 phone numbers (Signal, WhatsApp) if "+" in text: def _redact_phone(m): From fe62424ac481fb83a6f2df52b02635abbe624a64 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 30 May 2026 20:12:09 -0700 Subject: [PATCH 09/11] test(redact): assert Discord mentions pass through unchanged Rewrite TestDiscordMentions as negative assertions (mentions survive the redactor) and clean up the orphaned comment + dangling whitespace left by removing _DISCORD_MENTION_RE. Follow-up to the salvaged #32259 fix for #35611. --- tests/agent/test_redact.py | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index e4fa5e95043..e956b2a3ab9 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -342,39 +342,33 @@ class TestJWTTokens: class TestDiscordMentions: - """Discord snowflake IDs in <@ID> or <@!ID> format.""" + """Discord mention snowflakes (<@ID> / <@!ID>) are public syntax, not + secrets — they must pass through the redactor unchanged so multi-bot + @-pings (DISCORD_ALLOW_BOTS=mentions) keep resolving. See issue #35611.""" - def test_normal_mention(self): - result = redact_sensitive_text("Hello <@222589316709220353>") - assert "222589316709220353" not in result - assert "<@***>" in result + def test_normal_mention_passes_through(self): + text = "Hello <@222589316709220353>" + assert redact_sensitive_text(text) == text - def test_nickname_mention(self): - result = redact_sensitive_text("Ping <@!1331549159177846844>") - assert "1331549159177846844" not in result - assert "<@!***>" in result + def test_nickname_mention_passes_through(self): + text = "Ping <@!1331549159177846844>" + assert redact_sensitive_text(text) == text - def test_multiple_mentions(self): + def test_multiple_mentions_pass_through(self): text = "<@111111111111111111> and <@222222222222222222>" - result = redact_sensitive_text(text) - assert "111111111111111111" not in result - assert "222222222222222222" not in result + assert redact_sensitive_text(text) == text - def test_short_id_not_matched(self): - """IDs shorter than 17 digits are not Discord snowflakes.""" + def test_short_id_passes_through(self): text = "<@12345>" assert redact_sensitive_text(text) == text - def test_slack_mention_not_matched(self): - """Slack mentions use letters, not pure digits.""" + def test_slack_mention_passes_through(self): text = "<@U024BE7LH>" assert redact_sensitive_text(text) == text def test_preserves_surrounding_text(self): text = "User <@222589316709220353> said hello" - result = redact_sensitive_text(text) - assert result.startswith("User ") - assert result.endswith(" said hello") + assert redact_sensitive_text(text) == text class TestWebUrlsNotRedacted: From 50db2d9c12f2734b4ba8bbeb3c92a6e14e786a02 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 30 May 2026 20:57:01 -0700 Subject: [PATCH 10/11] feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659) * feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists deepseek/deepseek-v4-flash was already in the native deepseek provider catalog but missing from the curated OpenRouter and Nous Portal picker lists. Added it to both and regenerated the model-catalog.json manifest (drift guard requires same-PR regeneration). * refactor(models): trim redundant variants, group curated lists by maker Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex, gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20, and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment headers. Regenerated model-catalog.json (openrouter 27, nous 20). * feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists Adds google/gemini-3-pro-preview to both curated pickers (new on OpenRouter, restored on Nous). Regenerated model-catalog.json (openrouter 28, nous 21). * test(models): use claude-opus-4.8 in OpenRouter fetch fixtures The two TestFetchOpenRouterModels tests mocked a live OpenRouter response with claude-opus-4.6 and relied on it surviving the curated-list filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got filtered out and the recommended tag shifted. Swap the fixture to claude-opus-4.8 (still curated, still first in the Anthropic block). --- hermes_cli/models.py | 84 ++++++++------ tests/hermes_cli/test_models.py | 8 +- website/static/api/model-catalog.json | 156 ++++++++++---------------- 3 files changed, 117 insertions(+), 131 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index fba6ec94cfd..bacfafdba35 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -32,34 +32,43 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] # Fallback OpenRouter snapshot used when the live catalog is unavailable. # (model_id, display description shown in menus) OPENROUTER_MODELS: list[tuple[str, str]] = [ + # Anthropic ("anthropic/claude-opus-4.8", ""), ("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"), - ("anthropic/claude-opus-4.7", ""), - ("anthropic/claude-opus-4.6", ""), ("anthropic/claude-sonnet-4.6", ""), - ("moonshotai/kimi-k2.6", "recommended"), - ("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"), - ("qwen/qwen3.7-max", ""), ("anthropic/claude-haiku-4.5", ""), + # OpenAI ("openai/gpt-5.5", ""), ("openai/gpt-5.5-pro", ""), ("openai/gpt-5.4-mini", ""), - ("openai/gpt-5.4-nano", ""), - ("openai/gpt-5.3-codex", ""), - ("xiaomi/mimo-v2.5-pro", ""), - ("tencent/hy3-preview", ""), - ("google/gemini-3-pro-image-preview", ""), - ("google/gemini-3.5-flash", ""), + # Google + ("google/gemini-3-pro-preview", ""), ("google/gemini-3.1-pro-preview", ""), - ("google/gemini-3.1-flash-lite-preview", ""), - ("qwen/qwen3.6-35b-a3b", ""), - ("stepfun/step-3.7-flash", ""), - ("minimax/minimax-m2.7", ""), - ("z-ai/glm-5.1", ""), - ("x-ai/grok-4.20", ""), + ("google/gemini-3.5-flash", ""), + # xAI ("x-ai/grok-4.3", ""), - ("nvidia/nemotron-3-super-120b-a12b", ""), + # DeepSeek ("deepseek/deepseek-v4-pro", ""), + ("deepseek/deepseek-v4-flash", ""), + # Qwen + ("qwen/qwen3.7-max", ""), + ("qwen/qwen3.6-35b-a3b", ""), + # MoonshotAI + ("moonshotai/kimi-k2.6", "recommended"), + # MiniMax + ("minimax/minimax-m2.7", ""), + # Z-AI + ("z-ai/glm-5.1", ""), + # Xiaomi + ("xiaomi/mimo-v2.5-pro", ""), + # Tencent + ("tencent/hy3-preview", ""), + # StepFun + ("stepfun/step-3.7-flash", ""), + # NVIDIA + ("nvidia/nemotron-3-super-120b-a12b", ""), + # OpenRouter routers + ("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"), # Free tier ("openrouter/elephant-alpha", "free"), ("openrouter/owl-alpha", "free"), @@ -141,31 +150,40 @@ def _xai_curated_models() -> list[str]: _PROVIDER_MODELS: dict[str, list[str]] = { "nous": [ + # Anthropic "anthropic/claude-opus-4.8", - "anthropic/claude-opus-4.7", - "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", - "moonshotai/kimi-k2.6", - "qwen/qwen3.7-max", "anthropic/claude-haiku-4.5", + # OpenAI "openai/gpt-5.5", "openai/gpt-5.5-pro", "openai/gpt-5.4-mini", - "openai/gpt-5.4-nano", - "openai/gpt-5.3-codex", - "xiaomi/mimo-v2.5-pro", - "tencent/hy3-preview", + # Google "google/gemini-3-pro-preview", - "google/gemini-3.5-flash", "google/gemini-3.1-pro-preview", - "google/gemini-3.1-flash-lite-preview", - "qwen/qwen3.6-35b-a3b", - "stepfun/step-3.7-flash", - "minimax/minimax-m2.7", - "z-ai/glm-5.1", + "google/gemini-3.5-flash", + # xAI "x-ai/grok-4.3", - "nvidia/nemotron-3-super-120b-a12b", + # DeepSeek "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + # Qwen + "qwen/qwen3.7-max", + "qwen/qwen3.6-35b-a3b", + # MoonshotAI + "moonshotai/kimi-k2.6", + # MiniMax + "minimax/minimax-m2.7", + # Z-AI + "z-ai/glm-5.1", + # Xiaomi + "xiaomi/mimo-v2.5-pro", + # Tencent + "tencent/hy3-preview", + # StepFun + "stepfun/step-3.7-flash", + # NVIDIA + "nvidia/nemotron-3-super-120b-a12b", ], # Native OpenAI Chat Completions (api.openai.com). Used by /model counts and # provider_model_ids fallback when /v1/models is unavailable. diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index f965f361dec..d6ae4b1dd57 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -67,14 +67,14 @@ class TestFetchOpenRouterModels: return False def read(self): - return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}' + return b'{"data":[{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}' monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None) with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()): models = fetch_openrouter_models(force_refresh=True) assert models == [ - ("anthropic/claude-opus-4.6", "recommended"), + ("anthropic/claude-opus-4.8", "recommended"), ("qwen/qwen3.7-max", ""), ("nvidia/nemotron-3-super-120b-a12b:free", "free"), ] @@ -154,7 +154,7 @@ class TestFetchOpenRouterModels: # No supported_parameters field at all on either entry. return ( b'{"data":[' - b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},' + b'{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},' b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}' b']}' ) @@ -164,7 +164,7 @@ class TestFetchOpenRouterModels: models = fetch_openrouter_models(force_refresh=True) ids = [mid for mid, _ in models] - assert "anthropic/claude-opus-4.6" in ids + assert "anthropic/claude-opus-4.8" in ids assert "qwen/qwen3.7-max" in ids diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 13a147dfa74..c2b7c241ab1 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-29T11:20:16Z", + "updated_at": "2026-05-31T03:27:32Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -20,30 +20,10 @@ "id": "anthropic/claude-opus-4.8-fast", "description": "2x price, higher output speed" }, - { - "id": "anthropic/claude-opus-4.7", - "description": "" - }, - { - "id": "anthropic/claude-opus-4.6", - "description": "" - }, { "id": "anthropic/claude-sonnet-4.6", "description": "" }, - { - "id": "moonshotai/kimi-k2.6", - "description": "recommended" - }, - { - "id": "openrouter/pareto-code", - "description": "auto-routes to cheapest coder meeting openrouter.min_coding_score" - }, - { - "id": "qwen/qwen3.7-max", - "description": "" - }, { "id": "anthropic/claude-haiku-4.5", "description": "" @@ -61,11 +41,47 @@ "description": "" }, { - "id": "openai/gpt-5.4-nano", + "id": "google/gemini-3-pro-preview", "description": "" }, { - "id": "openai/gpt-5.3-codex", + "id": "google/gemini-3.1-pro-preview", + "description": "" + }, + { + "id": "google/gemini-3.5-flash", + "description": "" + }, + { + "id": "x-ai/grok-4.3", + "description": "" + }, + { + "id": "deepseek/deepseek-v4-pro", + "description": "" + }, + { + "id": "deepseek/deepseek-v4-flash", + "description": "" + }, + { + "id": "qwen/qwen3.7-max", + "description": "" + }, + { + "id": "qwen/qwen3.6-35b-a3b", + "description": "" + }, + { + "id": "moonshotai/kimi-k2.6", + "description": "recommended" + }, + { + "id": "minimax/minimax-m2.7", + "description": "" + }, + { + "id": "z-ai/glm-5.1", "description": "" }, { @@ -76,53 +92,17 @@ "id": "tencent/hy3-preview", "description": "" }, - { - "id": "google/gemini-3-pro-image-preview", - "description": "" - }, - { - "id": "google/gemini-3.5-flash", - "description": "" - }, - { - "id": "google/gemini-3.1-pro-preview", - "description": "" - }, - { - "id": "google/gemini-3.1-flash-lite-preview", - "description": "" - }, - { - "id": "qwen/qwen3.6-35b-a3b", - "description": "" - }, { "id": "stepfun/step-3.7-flash", "description": "" }, - { - "id": "minimax/minimax-m2.7", - "description": "" - }, - { - "id": "z-ai/glm-5.1", - "description": "" - }, - { - "id": "x-ai/grok-4.20", - "description": "" - }, - { - "id": "x-ai/grok-4.3", - "description": "" - }, { "id": "nvidia/nemotron-3-super-120b-a12b", "description": "" }, { - "id": "deepseek/deepseek-v4-pro", - "description": "" + "id": "openrouter/pareto-code", + "description": "auto-routes to cheapest coder meeting openrouter.min_coding_score" }, { "id": "openrouter/elephant-alpha", @@ -155,21 +135,9 @@ { "id": "anthropic/claude-opus-4.8" }, - { - "id": "anthropic/claude-opus-4.7" - }, - { - "id": "anthropic/claude-opus-4.6" - }, { "id": "anthropic/claude-sonnet-4.6" }, - { - "id": "moonshotai/kimi-k2.6" - }, - { - "id": "qwen/qwen3.7-max" - }, { "id": "anthropic/claude-haiku-4.5" }, @@ -182,35 +150,32 @@ { "id": "openai/gpt-5.4-mini" }, - { - "id": "openai/gpt-5.4-nano" - }, - { - "id": "openai/gpt-5.3-codex" - }, - { - "id": "xiaomi/mimo-v2.5-pro" - }, - { - "id": "tencent/hy3-preview" - }, { "id": "google/gemini-3-pro-preview" }, - { - "id": "google/gemini-3.5-flash" - }, { "id": "google/gemini-3.1-pro-preview" }, { - "id": "google/gemini-3.1-flash-lite-preview" + "id": "google/gemini-3.5-flash" + }, + { + "id": "x-ai/grok-4.3" + }, + { + "id": "deepseek/deepseek-v4-pro" + }, + { + "id": "deepseek/deepseek-v4-flash" + }, + { + "id": "qwen/qwen3.7-max" }, { "id": "qwen/qwen3.6-35b-a3b" }, { - "id": "stepfun/step-3.7-flash" + "id": "moonshotai/kimi-k2.6" }, { "id": "minimax/minimax-m2.7" @@ -219,13 +184,16 @@ "id": "z-ai/glm-5.1" }, { - "id": "x-ai/grok-4.3" + "id": "xiaomi/mimo-v2.5-pro" + }, + { + "id": "tencent/hy3-preview" + }, + { + "id": "stepfun/step-3.7-flash" }, { "id": "nvidia/nemotron-3-super-120b-a12b" - }, - { - "id": "deepseek/deepseek-v4-pro" } ] } From 02d1da49de5086946256cc157ff928dcffbe8ca1 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Sun, 31 May 2026 10:17:54 +0800 Subject: [PATCH 11/11] Block Hermes root config in media delivery --- gateway/platforms/base.py | 18 +++++++------ tests/gateway/test_platform_base.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 26a6274eef6..baa25b6024e 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -484,7 +484,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) from gateway.config import Platform, PlatformConfig from gateway.session import SessionSource, build_session_key -from hermes_constants import get_hermes_dir, get_hermes_home +from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = ( @@ -827,6 +827,7 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str: DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache") SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots") _HERMES_HOME = get_hermes_home() +_HERMES_ROOT = get_default_hermes_root() MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS" MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES" MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS" @@ -954,13 +955,14 @@ def _media_delivery_denied_paths() -> List[Path]: home = Path(os.path.expanduser("~")) for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS: denied.append(home / sub) - # The Hermes home itself contains credentials (auth.json, .env) and - # configuration (config.yaml) — only the cache subdirectories under it - # are explicitly allowlisted above. - denied.append(_HERMES_HOME / ".env") - denied.append(_HERMES_HOME / "auth.json") - denied.append(_HERMES_HOME / "credentials") - denied.append(_HERMES_HOME / "config.yaml") + # The active Hermes profile and shared Hermes root both contain control + # files and credentials. Only cache subdirectories under them are + # explicitly allowlisted above. + for hermes_root in (_HERMES_HOME, _HERMES_ROOT): + denied.append(hermes_root / ".env") + denied.append(hermes_root / "auth.json") + denied.append(hermes_root / "credentials") + denied.append(hermes_root / "config.yaml") return denied diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 2cc8118b7b2..c38070317a2 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -728,6 +728,45 @@ class TestMediaDeliveryDefaultMode: assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None + def test_denylist_blocks_hermes_config_in_active_profile(self, tmp_path, monkeypatch): + """The active profile config stays blocked in default mode.""" + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + config_file = hermes_dir / "config.yaml" + config_file.write_text("model:\n provider: openai\n") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._HERMES_HOME", + hermes_dir, + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None + + def test_denylist_blocks_shared_hermes_root_config_for_profiles(self, tmp_path, monkeypatch): + """Profile-mode gateways must still block the shared Hermes root config.""" + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + profile_home = fake_home / ".hermes" / "profiles" / "work" + profile_home.mkdir(parents=True) + hermes_root = fake_home / ".hermes" + config_file = hermes_root / "config.yaml" + config_file.write_text("profiles:\n active: work\n") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._HERMES_HOME", + profile_home, + ) + monkeypatch.setattr( + "gateway.platforms.base._HERMES_ROOT", + hermes_root, + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None + def test_strict_mode_envvar_restores_legacy_behavior(self, tmp_path, monkeypatch): """Setting HERMES_MEDIA_DELIVERY_STRICT=1 reactivates the older allowlist+recency logic. A stale file outside the allowlist is