From 18396af31ede5ce9127c966281eb748d89192156 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:50:07 -0700 Subject: [PATCH 01/14] fix: handle cross-device shutil.move failure in tirith auto-install (#10127) (#10524) _install_tirith() uses shutil.move() to place the binary from tmpdir to ~/.hermes/bin/. When these are on different filesystems (common in Docker, NFS), shutil.move() falls back to copy2 + unlink, but copy2's metadata step can raise PermissionError. This exception propagated past the fail_open guard, crashing the terminal tool entirely. Additionally, a failed install could leave a non-executable tirith binary at the destination, causing a retry loop on every subsequent terminal command. Fix: - Catch OSError from shutil.move() and fall back to shutil.copy() (skips metadata/xattr copying that causes PermissionError) - If even copy fails, clean up the partial dest file to prevent the non-executable retry loop - Return (None, 'cross_device_copy_failed') so the failure routes through the existing install-failure caching and fail_open logic Closes #10127 --- tools/tirith_security.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index b3055944..44710ee6 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -360,7 +360,21 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: src = os.path.join(tmpdir, "tirith") dest = os.path.join(_hermes_bin_dir(), "tirith") - shutil.move(src, dest) + try: + shutil.move(src, dest) + except OSError: + # Cross-device move (common in Docker, NFS): shutil.move() falls + # back to copy2 + unlink, but copy2's metadata step can raise + # PermissionError. Use plain copy + manual chmod instead. + try: + shutil.copy(src, dest) + except OSError: + # Clean up partial dest to prevent a non-executable retry loop + try: + os.unlink(dest) + except OSError: + pass + return None, "cross_device_copy_failed" os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only" From 096260ce7852910470d6cc142e87174893db17d1 Mon Sep 17 00:00:00 2001 From: Junass1 Date: Wed, 15 Apr 2026 04:09:14 +0300 Subject: [PATCH 02/14] fix(telegram): authorize update prompt callbacks --- gateway/platforms/telegram.py | 22 +++++-- scripts/release.py | 2 +- .../gateway/test_telegram_approval_buttons.py | 62 +++++++++++++++++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 112b232d..0806362b 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -163,6 +163,15 @@ class TelegramAdapter(BasePlatformAdapter): # Approval button state: message_id → session_key self._approval_state: Dict[int, str] = {} + @staticmethod + def _is_callback_user_authorized(user_id: str) -> bool: + """Return whether a Telegram inline-button caller may perform gated actions.""" + allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() + if not allowed_csv: + return True + allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} + return "*" in allowed_ids or user_id in allowed_ids + def _fallback_ips(self) -> list[str]: """Return validated fallback IPs from config (populated by _apply_env_overrides).""" configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else [] @@ -1440,12 +1449,9 @@ class TelegramAdapter(BasePlatformAdapter): # Only authorized users may click approval buttons. caller_id = str(getattr(query.from_user, "id", "")) - allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() - if allowed_csv: - allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} - if "*" not in allowed_ids and caller_id not in allowed_ids: - await query.answer(text="⛔ You are not authorized to approve commands.") - return + if not self._is_callback_user_authorized(caller_id): + await query.answer(text="⛔ You are not authorized to approve commands.") + return session_key = self._approval_state.pop(approval_id, None) if not session_key: @@ -1490,6 +1496,10 @@ class TelegramAdapter(BasePlatformAdapter): if not data.startswith("update_prompt:"): return answer = data.split(":", 1)[1] # "y" or "n" + caller_id = str(getattr(query.from_user, "id", "")) + if not self._is_callback_user_authorized(caller_id): + await query.answer(text="⛔ You are not authorized to answer update prompts.") + return await query.answer(text=f"Sent '{answer}' to the update process.") # Edit the message to show the choice and remove buttons label = "Yes" if answer == "y" else "No" diff --git a/scripts/release.py b/scripts/release.py index 73d663e5..035fb096 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -191,7 +191,7 @@ AUTHOR_MAP = { "yangzhi.see@gmail.com": "SeeYangZhi", "yongtenglei@gmail.com": "yongtenglei", "young@YoungdeMacBook-Pro.local": "YoungYang963", - "ysfalweshcan@gmail.com": "Awsh1", + "ysfalweshcan@gmail.com": "Junass1", "ysfwaxlycan@gmail.com": "WAXLYY", "yusufalweshdemir@gmail.com": "Dusk1e", "zhouboli@gmail.com": "zhouboli", diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 98d3cdc3..ec5bbd47 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -263,7 +263,7 @@ class TestTelegramApprovalCallback: mock_resolve.assert_not_called() @pytest.mark.asyncio - async def test_update_prompt_callback_not_affected(self): + async def test_update_prompt_callback_not_affected(self, tmp_path): """Ensure update prompt callbacks still work.""" adapter = _make_adapter() @@ -281,11 +281,63 @@ class TestTelegramApprovalCallback: context = MagicMock() with patch("tools.approval.resolve_gateway_approval") as mock_resolve: - with patch("hermes_constants.get_hermes_home", return_value=Path("/tmp/test")): - try: + with patch("hermes_constants.get_hermes_home", return_value=tmp_path): + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": ""}): await adapter._handle_callback_query(update, context) - except Exception: - pass # May fail on file write, that's fine # Should NOT have triggered approval resolution mock_resolve.assert_not_called() + assert (tmp_path / ".update_response").read_text() == "y" + + @pytest.mark.asyncio + async def test_update_prompt_callback_rejects_unauthorized_user(self, tmp_path): + """Update prompt buttons should honor TELEGRAM_ALLOWED_USERS.""" + adapter = _make_adapter() + + query = AsyncMock() + query.data = "update_prompt:y" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.from_user.id = 222 + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + with patch("hermes_constants.get_hermes_home", return_value=tmp_path): + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "111"}): + await adapter._handle_callback_query(update, context) + + query.answer.assert_called_once() + assert "not authorized" in query.answer.call_args[1]["text"].lower() + query.edit_message_text.assert_not_called() + assert not (tmp_path / ".update_response").exists() + + @pytest.mark.asyncio + async def test_update_prompt_callback_allows_authorized_user(self, tmp_path): + """Allowed Telegram users can still answer update prompt buttons.""" + adapter = _make_adapter() + + query = AsyncMock() + query.data = "update_prompt:n" + query.message = MagicMock() + query.message.chat_id = 12345 + query.from_user = MagicMock() + query.from_user.id = 111 + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + with patch("hermes_constants.get_hermes_home", return_value=tmp_path): + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "111"}): + await adapter._handle_callback_query(update, context) + + query.answer.assert_called_once() + query.edit_message_text.assert_called_once() + assert (tmp_path / ".update_response").read_text() == "n" From 23f1fa22af4cf94b6d6cb5bafa1326e1b58c9557 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:54:30 -0700 Subject: [PATCH 03/14] fix(kimi): include kimi-coding-cn in Kimi base URL resolution (#10534) Route kimi-coding-cn through _resolve_kimi_base_url() in both get_api_key_provider_status() and resolve_api_key_provider_credentials() so CN users with sk-kimi- prefixed keys get auto-detected to the Kimi Coding Plan endpoint, matching the existing behavior for kimi-coding. Also update the kimi-coding display label to accurately reflect the dual-endpoint setup (Kimi Coding Plan + Moonshot API). Salvaged from PR #10525 by kkikione999. --- hermes_cli/auth.py | 4 ++-- hermes_cli/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 636416a9..1fd9a303 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2384,7 +2384,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id == "kimi-coding": + if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: base_url = env_url @@ -2470,7 +2470,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id == "kimi-coding": + if provider_id in ("kimi-coding", "kimi-coding-cn"): base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 18f29c6c..62c21504 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -526,7 +526,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"), ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"), ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"), - ProviderEntry("kimi-coding", "Kimi / Moonshot", "Kimi / Moonshot (Moonshot AI direct API)"), + ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com) & Moonshot API"), ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"), ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"), ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"), From d4eba82a377a72c6b08212c49720c4905e04226f Mon Sep 17 00:00:00 2001 From: LehaoLin Date: Thu, 16 Apr 2026 05:02:34 +0800 Subject: [PATCH 04/14] fix(streaming): don't suppress final response when commentary message is sent Commentary messages (interim assistant status updates like "Using browser tool...") are sent via _send_commentary(), which was incorrectly setting _already_sent = True on success. This caused the final response to be suppressed when there were multiple tool calls, because the gateway checks already_sent to decide whether to skip re-sending the response. The fix: commentary messages are interim status updates, not the final response, so _already_sent should not be set when they succeed. This ensures the final response is always delivered regardless of how many commentary messages were sent during the turn. Fixes: #10454 --- gateway/stream_consumer.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index e6d96c80..50321a30 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -609,12 +609,15 @@ class GatewayStreamConsumer: content=text, metadata=self.metadata, ) - if result.success: - self._already_sent = True - return True + # Note: do NOT set _already_sent = True here. + # Commentary messages are interim status updates (e.g. "Using browser + # tool..."), not the final response. Setting already_sent would cause + # the final response to be incorrectly suppressed when there are + # multiple tool calls. See: https://github.com/NousResearch/hermes-agent/issues/10454 + return result.success except Exception as e: logger.error("Commentary send error: %s", e) - return False + return False async def _send_or_edit(self, text: str) -> bool: """Send or edit the streaming message. From efd1ddc6e1632871aa7771af8f2df5bed2cd2ed0 Mon Sep 17 00:00:00 2001 From: MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:14:52 +0000 Subject: [PATCH 05/14] fix: sanitize api_messages and extra string fields during ASCII-codec recovery (#6843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ASCII-locale recovery path in run_agent.py sanitized the canonical 'messages' list but left 'api_messages' untouched. api_messages is a separate API-copy built before the retry loop and may carry extra fields (reasoning_content, extra_body entries) that are not present in 'messages'. This caused the retry to still raise UnicodeEncodeError even after the 'System encoding is ASCII — stripped...' log line appeared. Two changes: - _sanitize_messages_non_ascii now walks all extra top-level string fields in each message dict (any key not in {content, name, tool_calls, role}) so reasoning_content and future extras are cleaned in both 'messages' and 'api_messages'. - The ASCII-codec recovery block now also calls sanitize on api_messages and api_kwargs so no non-ASCII survives into the next retry attempt. Adds regression tests covering: - reasoning_content with non-ASCII in api_messages - extra_body with non-ASCII in api_kwargs - canonical messages clean but api_messages dirty Fixes #6843 --- run_agent.py | 21 +++++++ tests/run_agent/test_unicode_ascii_codec.py | 67 ++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index 244fea6b..a181b11a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -457,6 +457,15 @@ def _sanitize_messages_non_ascii(messages: list) -> bool: if sanitized != fn_args: fn["arguments"] = sanitized found = True + # Sanitize any additional top-level string fields (e.g. reasoning_content) + for key, value in msg.items(): + if key in {"content", "name", "tool_calls", "role"}: + continue + if isinstance(value, str): + sanitized = _strip_non_ascii(value) + if sanitized != value: + msg[key] = sanitized + found = True return found @@ -9107,7 +9116,19 @@ class AIAgent: # ASCII codec: the system encoding can't handle # non-ASCII characters at all. Sanitize all # non-ASCII content from messages/tool schemas and retry. + # Sanitize both the canonical `messages` list and + # `api_messages` (the API-copy built before the retry + # loop, which may contain extra fields like + # reasoning_content that are not in `messages`). _messages_sanitized = _sanitize_messages_non_ascii(messages) + if isinstance(api_messages, list): + _sanitize_messages_non_ascii(api_messages) + # Also sanitize the last api_kwargs if already built, + # so a leftover non-ASCII value in a transformed field + # (e.g. extra_body, reasoning_content) doesn't survive + # into the next attempt via _build_api_kwargs cache paths. + if isinstance(api_kwargs, dict): + _sanitize_structure_non_ascii(api_kwargs) _prefill_sanitized = False if isinstance(getattr(self, "prefill_messages", None), list): _prefill_sanitized = _sanitize_messages_non_ascii(self.prefill_messages) diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index a8a52c34..714429b3 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -268,9 +268,9 @@ class TestApiKeyClientSync: agent.client.api_key = _clean_key # All three locations should now hold the clean key - assert agent.api_key == "sk-proj-abcdef" - assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" - assert agent.client.api_key == "sk-proj-abcdef" + assert agent.api_key == "***" + assert agent._client_kwargs["api_key"] == "***" + assert agent.client.api_key == "***" # The bad char should be gone from all of them assert "\u028b" not in agent.api_key assert "\u028b" not in agent._client_kwargs["api_key"] @@ -294,3 +294,64 @@ class TestApiKeyClientSync: assert agent.api_key == "sk-proj-" assert agent.client is None # should not have been touched + + +class TestApiMessagesAndApiKwargsSanitized: + """Regression tests for #6843 follow-up: api_messages and api_kwargs must + be sanitized alongside messages during ASCII-codec recovery. + + The original fix only sanitized the canonical `messages` list. + api_messages is a separate API-copy built before the retry loop; it may + carry extra fields (reasoning_content, extra_body) with non-ASCII chars + that are not present in `messages`. Without sanitizing api_messages and + api_kwargs, the retry still raises UnicodeEncodeError even after the + 'System encoding is ASCII — stripped...' log line appears. + """ + + def test_api_messages_with_reasoning_content_is_sanitized(self): + """api_messages may contain reasoning_content not in messages.""" + api_messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "Sure!", + # reasoning_content is injected by the API-copy builder and + # is NOT present in the canonical messages list + "reasoning_content": "Let me think \xab step by step \xbb", + }, + ] + found = _sanitize_messages_non_ascii(api_messages) + assert found is True + assert "\xab" not in api_messages[2]["reasoning_content"] + assert "\xbb" not in api_messages[2]["reasoning_content"] + + def test_api_kwargs_with_non_ascii_extra_body_is_sanitized(self): + """api_kwargs may contain non-ASCII in extra_body or other fields.""" + api_kwargs = { + "model": "glm-5.1", + "messages": [{"role": "user", "content": "ok"}], + "extra_body": { + "system": "Think carefully \u2192 answer", + }, + } + found = _sanitize_structure_non_ascii(api_kwargs) + assert found is True + assert "\u2192" not in api_kwargs["extra_body"]["system"] + + def test_messages_clean_but_api_messages_dirty_both_get_sanitized(self): + """Even when canonical messages are clean, api_messages may be dirty.""" + messages = [{"role": "user", "content": "hello"}] + api_messages = [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "ok", + "reasoning_content": "step \xab done", + }, + ] + # messages sanitize returns False (nothing to clean) + assert _sanitize_messages_non_ascii(messages) is False + # api_messages sanitize must catch the dirty reasoning_content + assert _sanitize_messages_non_ascii(api_messages) is True + assert "\xab" not in api_messages[1]["reasoning_content"] From 902f1e6ede20dd618d64aa6dccced966675f8316 Mon Sep 17 00:00:00 2001 From: MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:19:55 +0000 Subject: [PATCH 06/14] chore: add MestreY0d4-Uninter to AUTHOR_MAP and .mailmap --- .mailmap | 1 + scripts/release.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.mailmap b/.mailmap index 0c385c51..3f093fb5 100644 --- a/.mailmap +++ b/.mailmap @@ -105,3 +105,4 @@ tesseracttars-creator xinbenlv SaulJWu angelos +MestreY0d4-Uninter <241404605+MestreY0d4-Uninter@users.noreply.github.com> diff --git a/scripts/release.py b/scripts/release.py index 035fb096..b533f94a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -63,6 +63,7 @@ AUTHOR_MAP = { "70424851+insecurejezza@users.noreply.github.com": "insecurejezza", "259807879+Bartok9@users.noreply.github.com": "Bartok9", "268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1", + "241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter", # contributors (manual mapping from git names) "dmayhem93@gmail.com": "dmahan93", "samherring99@gmail.com": "samherring99", From 93b6f4522479a7c92ef8dc6a75d71c8c83b7e7f1 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 14:56:55 -0700 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20always=20retry=20on=20ASCII=20code?= =?UTF-8?q?c=20UnicodeEncodeError=20=E2=80=94=20don't=20gate=20on=20per-co?= =?UTF-8?q?mponent=20sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery block previously only retried (continue) when one of the per-component sanitization checks (messages, tools, system prompt, headers, credentials) found and stripped non-ASCII content. When the non-ASCII lived only in api_messages' reasoning_content field (which is built from messages['reasoning'] and not checked by the original _sanitize_messages_non_ascii), all checks returned False and the recovery fell through to the normal error path — burning a retry attempt despite _force_ascii_payload being set. Now the recovery always continues (retries) when _is_ascii_codec is detected. The _force_ascii_payload flag guarantees the next iteration runs _sanitize_structure_non_ascii(api_kwargs) on the full API payload, catching any remaining non-ASCII regardless of where it lives. Also adds test for the 'reasoning' field on canonical messages. Fixes #6843 --- run_agent.py | 24 +++++++++++++++------ tests/run_agent/test_unicode_ascii_codec.py | 21 +++++++++++++++--- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/run_agent.py b/run_agent.py index a181b11a..b0110781 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9186,22 +9186,34 @@ class AIAgent: force=True, ) - if ( + # Always retry on ASCII codec detection — + # _force_ascii_payload guarantees the full + # api_kwargs payload is sanitized on the + # next iteration (line ~8475). Even when + # per-component checks above find nothing + # (e.g. non-ASCII only in api_messages' + # reasoning_content), the flag catches it. + # Bounded by _unicode_sanitization_passes < 2. + self._unicode_sanitization_passes += 1 + _any_sanitized = ( _messages_sanitized or _prefill_sanitized or _tools_sanitized or _system_sanitized or _headers_sanitized or _credential_sanitized - ): - self._unicode_sanitization_passes += 1 + ) + if _any_sanitized: self._vprint( f"{self.log_prefix}⚠️ System encoding is ASCII — stripped non-ASCII characters from request payload. Retrying...", force=True, ) - continue - # Nothing to sanitize in any payload component. - # Fall through to normal error path. + else: + self._vprint( + f"{self.log_prefix}⚠️ System encoding is ASCII — enabling full-payload sanitization for retry...", + force=True, + ) + continue status_code = getattr(api_error, "status_code", None) error_context = self._extract_api_error_context(api_error) diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index 714429b3..04b5e404 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -268,9 +268,9 @@ class TestApiKeyClientSync: agent.client.api_key = _clean_key # All three locations should now hold the clean key - assert agent.api_key == "***" - assert agent._client_kwargs["api_key"] == "***" - assert agent.client.api_key == "***" + assert agent.api_key == "sk-proj-abcdef" + assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" + assert agent.client.api_key == "sk-proj-abcdef" # The bad char should be gone from all of them assert "\u028b" not in agent.api_key assert "\u028b" not in agent._client_kwargs["api_key"] @@ -355,3 +355,18 @@ class TestApiMessagesAndApiKwargsSanitized: # api_messages sanitize must catch the dirty reasoning_content assert _sanitize_messages_non_ascii(api_messages) is True assert "\xab" not in api_messages[1]["reasoning_content"] + + def test_reasoning_field_in_canonical_messages_is_sanitized(self): + """The canonical messages list stores reasoning as 'reasoning', not + 'reasoning_content'. The extra-fields loop must catch it.""" + messages = [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "ok", + "reasoning": "Let me think \xab carefully \xbb", + }, + ] + assert _sanitize_messages_non_ascii(messages) is True + assert "\xab" not in messages[1]["reasoning"] + assert "\xbb" not in messages[1]["reasoning"] From 3b4ecf8ee70fcaca38a75f897a8f6c5ef725aafe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:04:01 -0700 Subject: [PATCH 08/14] fix: remove 'q' alias from /quit so /queue's 'q' alias works (#10467) (#10538) Both /queue and /quit registered 'q' as an alias. Since /quit appeared later in COMMAND_REGISTRY, _build_command_lookup() silently overwrote /queue's claim, making the documented /queue shorthand unusable. Fix: remove 'q' from /quit's aliases. /quit already has 'exit' as an alias plus the full '/quit' command. /queue has no other short alias. Closes #10467 --- hermes_cli/commands.py | 2 +- tests/hermes_cli/test_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 516392bd..c8a0628f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -164,7 +164,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Exit CommandDef("quit", "Exit the CLI", "Exit", - cli_only=True, aliases=("exit", "q")), + cli_only=True, aliases=("exit",)), ] diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 5912194b..8b359709 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -97,7 +97,7 @@ class TestResolveCommand: def test_alias_resolves_to_canonical(self): assert resolve_command("bg").name == "background" assert resolve_command("reset").name == "new" - assert resolve_command("q").name == "quit" + assert resolve_command("q").name == "queue" assert resolve_command("exit").name == "quit" assert resolve_command("gateway").name == "platforms" assert resolve_command("set-home").name == "sethome" From 96cc556055f5c6ab382197f86d675f59557a3a7e Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:05:03 -0600 Subject: [PATCH 09/14] fix(copilot): preserve base URL and gpt-5-mini routing --- agent/credential_pool.py | 2 + run_agent.py | 46 +++++++++++++++---- tests/agent/test_credential_pool.py | 1 + .../test_run_agent_codex_responses.py | 17 +++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 8a2fecf5..e1307e51 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1162,6 +1162,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup if token: source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}" active_sources.add(source_name) + pconfig = PROVIDER_REGISTRY.get(provider) changed |= _upsert_entry( entries, provider, @@ -1170,6 +1171,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup "source": source_name, "auth_type": AUTH_TYPE_API_KEY, "access_token": token, + "base_url": pconfig.inference_base_url if pconfig else "", "label": source, }, ) diff --git a/run_agent.py b/run_agent.py index b0110781..956a1e96 100644 --- a/run_agent.py +++ b/run_agent.py @@ -714,12 +714,13 @@ class AIAgent: except Exception: pass - # GPT-5.x models require the Responses API path — they are rejected - # on /v1/chat/completions by both OpenAI and OpenRouter. Also - # auto-upgrade for direct OpenAI URLs (api.openai.com) since all - # newer tool-calling models prefer Responses there. - # ACP runtimes are excluded: CopilotACPClient handles its own - # routing and does not implement the Responses API surface. + # GPT-5.x models usually require the Responses API path, but some + # providers have exceptions (for example Copilot's gpt-5-mini still + # uses chat completions). Also auto-upgrade for direct OpenAI URLs + # (api.openai.com) since all newer tool-calling models prefer + # Responses there. ACP runtimes are excluded: CopilotACPClient + # handles its own routing and does not implement the Responses API + # surface. if ( self.api_mode == "chat_completions" and self.provider != "copilot-acp" @@ -727,7 +728,10 @@ class AIAgent: and not str(self.base_url or "").lower().startswith("acp+tcp://") and ( self._is_direct_openai_url() - or self._model_requires_responses_api(self.model) + or self._provider_model_requires_responses_api( + self.model, + provider=self.provider, + ) ) ): self.api_mode = "codex_responses" @@ -1960,6 +1964,24 @@ class AIAgent: m = m.rsplit("/", 1)[-1] return m.startswith("gpt-5") + @staticmethod + def _provider_model_requires_responses_api( + model: str, + *, + provider: Optional[str] = None, + ) -> bool: + """Return True when this provider/model pair should use Responses API.""" + normalized_provider = (provider or "").strip().lower() + if normalized_provider == "copilot": + try: + from hermes_cli.models import _should_use_copilot_responses_api + return _should_use_copilot_responses_api(model) + except Exception: + # Fall back to the generic GPT-5 rule if Copilot-specific + # logic is unavailable for any reason. + pass + return AIAgent._model_requires_responses_api(model) + def _max_tokens_param(self, value: int) -> dict: """Return the correct max tokens kwarg for the current provider. @@ -5729,9 +5751,13 @@ class AIAgent: fb_api_mode = "anthropic_messages" elif self._is_direct_openai_url(fb_base_url): fb_api_mode = "codex_responses" - elif self._model_requires_responses_api(fb_model): - # GPT-5.x models need Responses API on every provider - # (OpenRouter, Copilot, direct OpenAI, etc.) + elif self._provider_model_requires_responses_api( + fb_model, + provider=fb_provider, + ): + # GPT-5.x models usually need Responses API, but keep + # provider-specific exceptions like Copilot gpt-5-mini on + # chat completions. fb_api_mode = "codex_responses" old_model = self.model diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index ca232c12..c11782f6 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1091,6 +1091,7 @@ def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch): assert len(entries) == 1 assert entries[0].source == "gh_cli" assert entries[0].access_token == "gho_fake_token_abc123" + assert entries[0].base_url == "https://api.githubcopilot.com" def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch): diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 2b229556..4ff00018 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -259,6 +259,23 @@ def test_copilot_acp_stays_on_chat_completions_for_gpt_5_models(monkeypatch): assert agent.api_mode == "chat_completions" +def test_copilot_gpt_5_mini_stays_on_chat_completions(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5-mini", + base_url="https://api.githubcopilot.com", + provider="copilot", + api_key="gh-token", + api_mode="chat_completions", + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + ) + assert agent.provider == "copilot" + assert agent.api_mode == "chat_completions" + + def test_build_api_kwargs_codex(monkeypatch): agent = _build_agent(monkeypatch) kwargs = agent._build_api_kwargs( From ddaadfb9f0770fa2bff2d73785b4957fbb5619d2 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 14:59:35 -0700 Subject: [PATCH 10/14] chore: add helix4u to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b533f94a..752cffd9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -78,6 +78,7 @@ AUTHOR_MAP = { "hakanerten02@hotmail.com": "teyrebaz33", "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", + "4317663+helix4u@users.noreply.github.com": "helix4u", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", From f1df83179f77776bb56dbda30e144e7728beb552 Mon Sep 17 00:00:00 2001 From: Harish Kukreja <331214+counterposition@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:49:59 -0400 Subject: [PATCH 11/14] fix(doctor): skip health check for OpenCode Go (no shared /models endpoint) OpenCode Go does not expose a shared /models endpoint, so the doctor probe was always failing and producing a false warning. Set the default URL to None and disable the health check for this provider. --- hermes_cli/doctor.py | 3 +- tests/hermes_cli/test_doctor.py | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index b89a8040..69a24aff 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -814,7 +814,8 @@ def run_doctor(args): ("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True), ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True), - ("OpenCode Go", ("OPENCODE_GO_API_KEY",), "https://opencode.ai/zen/go/v1/models", "OPENCODE_GO_BASE_URL", True), + # OpenCode Go has no shared /models endpoint; skip the health check. + ("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False), ] for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers: _key = "" diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index dd15336f..948cafaf 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -343,3 +343,57 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, assert "Kimi / Moonshot (China)" in out assert "str expected, not NoneType" not in out assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls) + + +@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"]) +def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + (home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test") + if base_url: + monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url) + else: + monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except ImportError: + pass + + calls = [] + + def fake_get(url, headers=None, timeout=None): + calls.append((url, headers, timeout)) + return types.SimpleNamespace(status_code=200) + + import httpx + monkeypatch.setattr(httpx, "get", fake_get) + + import io, contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert any( + "OpenCode Go" in line and "(key configured)" in line + for line in out.splitlines() + ) + assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls) + assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls) From eb3d928da6a8b3dd5823b159e4d9cff250779d21 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 15:05:11 -0700 Subject: [PATCH 12/14] chore: add counterposition to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 752cffd9..445750a3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -79,6 +79,7 @@ AUTHOR_MAP = { "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", "4317663+helix4u@users.noreply.github.com": "helix4u", + "331214+counterposition@users.noreply.github.com": "counterposition", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", From de3f8bc6cef8eb0cd0a0b41e6750a110f7ac87f5 Mon Sep 17 00:00:00 2001 From: Ruzzgar Date: Wed, 15 Apr 2026 02:56:31 +0300 Subject: [PATCH 13/14] fix terminal workdir validation for Windows paths --- scripts/release.py | 1 + tests/tools/test_terminal_tool.py | 15 +++++++++++++++ tools/terminal_tool.py | 7 ++++--- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 445750a3..b40fd7c2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -76,6 +76,7 @@ AUTHOR_MAP = { "abdullahfarukozden@gmail.com": "Farukest", "lovre.pesut@gmail.com": "rovle", "hakanerten02@hotmail.com": "teyrebaz33", + "ruzzgarcn@gmail.com": "Ruzzgar", "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", "4317663+helix4u@users.noreply.github.com": "helix4u", diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py index 42ed693a..dd2a6741 100644 --- a/tests/tools/test_terminal_tool.py +++ b/tests/tools/test_terminal_tool.py @@ -88,3 +88,18 @@ def test_cached_sudo_password_is_used_when_env_is_unset(monkeypatch): assert transformed == "echo ok && sudo -S -p '' whoami" assert sudo_stdin == "cached-pass\n" + + +def test_validate_workdir_allows_windows_drive_paths(): + assert terminal_tool._validate_workdir(r"C:\Users\Alice\project") is None + assert terminal_tool._validate_workdir("C:/Users/Alice/project") is None + + +def test_validate_workdir_allows_windows_unc_paths(): + assert terminal_tool._validate_workdir(r"\\server\share\project") is None + + +def test_validate_workdir_blocks_shell_metacharacters_in_windows_paths(): + assert terminal_tool._validate_workdir(r"C:\Users\Alice\project; rm -rf /") + assert terminal_tool._validate_workdir(r"C:\Users\Alice\project$(whoami)") + assert terminal_tool._validate_workdir("C:\\Users\\Alice\\project\nwhoami") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 55f4c10a..1aa26652 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -148,9 +148,10 @@ def _check_all_guards(command: str, env_type: str) -> dict: # Allowlist: characters that can legitimately appear in directory paths. -# Covers alphanumeric, path separators, tilde, dot, hyphen, underscore, space, -# plus, at, equals, and comma. Everything else is rejected. -_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/_\-.~ +@=,]+$') +# Covers alphanumeric, path separators, Windows drive/UNC separators, tilde, +# dot, hyphen, underscore, space, plus, at, equals, and comma. Everything +# else is rejected. +_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/\\:_\-.~ +@=,]+$') def _validate_workdir(workdir: str) -> str | None: From 1d4b9c1a7400d54d2178a327981ca65d25f7cb73 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:09:23 -0700 Subject: [PATCH 14/14] fix(gateway): don't treat group session user_id as thread_id in shutdown notifications (#10546) _parse_session_key() blindly assigned parts[5] as thread_id for all chat types. For group sessions with per-user isolation, parts[5] is a user_id, not a thread_id. This could cause shutdown notifications to route with incorrect thread metadata. Only return thread_id for chat types where the 6th element is unambiguous: dm and thread. For group/channel sessions, omit thread_id since the suffix may be a user_id. Based on the approach from PR #9938 by @Ruzzgar. --- gateway/run.py | 9 ++++++-- .../test_background_process_notifications.py | 22 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 361678de..80797358 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -486,9 +486,14 @@ def _parse_session_key(session_key: str) -> "dict | None": """Parse a session key into its component parts. Session keys follow the format - ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``. + ``agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]``. Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and optionally ``thread_id`` keys, or None if the key doesn't match. + + The 6th element is only returned as ``thread_id`` for chat types where + it is unambiguous (``dm`` and ``thread``). For group/channel sessions + the suffix may be a user_id (per-user isolation) rather than a + thread_id, so we leave ``thread_id`` out to avoid mis-routing. """ parts = session_key.split(":") if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": @@ -497,7 +502,7 @@ def _parse_session_key(session_key: str) -> "dict | None": "chat_type": parts[3], "chat_id": parts[4], } - if len(parts) > 5: + if len(parts) > 5 and parts[3] in ("dm", "thread"): result["thread_id"] = parts[5] return result return None diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index eabf92be..7351854a 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -383,15 +383,27 @@ def test_parse_session_key_valid(): def test_parse_session_key_with_extra_parts(): - """Thread ID (6th part) is extracted; further parts are ignored.""" + """6th part in a group key may be a user_id, not a thread_id — omit it.""" result = _parse_session_key("agent:main:discord:group:chan123:thread456") - assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123", "thread_id": "thread456"} + assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"} def test_parse_session_key_with_user_id_part(): - """7th part (user_id) is ignored — only up to thread_id is extracted.""" - result = _parse_session_key("agent:main:telegram:group:chat1:thread42:user99") - assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1", "thread_id": "thread42"} + """Group keys with per-user isolation have user_id as 6th part — don't return as thread_id.""" + result = _parse_session_key("agent:main:telegram:group:chat1:user99") + assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1"} + + +def test_parse_session_key_dm_with_thread(): + """DM keys use parts[5] as thread_id unambiguously.""" + result = _parse_session_key("agent:main:telegram:dm:chat1:topic42") + assert result == {"platform": "telegram", "chat_type": "dm", "chat_id": "chat1", "thread_id": "topic42"} + + +def test_parse_session_key_thread_chat_type(): + """Thread-typed keys use parts[5] as thread_id unambiguously.""" + result = _parse_session_key("agent:main:discord:thread:chan1:thread99") + assert result == {"platform": "discord", "chat_type": "thread", "chat_id": "chan1", "thread_id": "thread99"} def test_parse_session_key_too_short():