From ccf7bb1102e7adb3cda323f4163d2fe918b87a2a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:38:10 -0700 Subject: [PATCH 01/52] fix(nous): use curated model list instead of full API dump for Nous Portal (#3867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three Nous Portal model selection paths (hermes model, first-time login, setup wizard) were hitting the live /models endpoint and showing every model available — potentially hundreds. Now uses the curated _PROVIDER_MODELS['nous'] list (25 agentic models matching OpenRouter defaults) with 'Enter custom model name' for anything else. Fixed in: - hermes_cli/main.py: _model_flow_nous() - hermes_cli/auth.py: _login_nous() model selection - hermes_cli/setup.py: post-login model selection --- hermes_cli/auth.py | 13 ++++++------- hermes_cli/main.py | 24 +++++++++++++----------- hermes_cli/setup.py | 7 +++---- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0c50370198..940a155645 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2310,21 +2310,20 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: raise AuthError("No runtime API key available to fetch models", provider="nous", code="invalid_token") - model_ids = fetch_nous_models( - inference_base_url=runtime_base_url, - api_key=runtime_key, - timeout_seconds=timeout_seconds, - verify=verify, - ) + # Use curated model list (same as OpenRouter defaults) instead + # of the full /models dump which returns hundreds of models. + from hermes_cli.models import _PROVIDER_MODELS + model_ids = _PROVIDER_MODELS.get("nous", []) print() if model_ids: + print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") selected_model = _prompt_model_selection(model_ids) if selected_model: _save_model_choice(selected_model) print(f"Default model set to: {selected_model}") else: - print("No models were returned by the inference API.") + print("No curated models available for Nous Portal.") except Exception as exc: message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) print() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index cf445d8452..39d233ad46 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1084,14 +1084,20 @@ def _model_flow_nous(config, current_model=""): # login_nous already handles model selection + config update return - # Already logged in — fetch models and select - print("Fetching models from Nous Portal...") + # Already logged in — use curated model list (same as OpenRouter defaults). + # The live /models endpoint returns hundreds of models; the curated list + # shows only agentic models users recognize from OpenRouter. + from hermes_cli.models import _PROVIDER_MODELS + model_ids = _PROVIDER_MODELS.get("nous", []) + if not model_ids: + print("No curated models available for Nous Portal.") + return + + print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") + + # Verify credentials are still valid (catches expired sessions early) try: creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60) - model_ids = fetch_nous_models( - inference_base_url=creds.get("base_url", ""), - api_key=creds.get("api_key", ""), - ) except Exception as exc: relogin = isinstance(exc, AuthError) and exc.relogin_required msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) @@ -1108,11 +1114,7 @@ def _model_flow_nous(config, current_model=""): except Exception as login_exc: print(f"Re-login failed: {login_exc}") return - print(f"Could not fetch models: {msg}") - return - - if not model_ids: - print("No models returned by the inference API.") + print(f"Could not verify credentials: {msg}") return selected = _prompt_model_selection(model_ids, current_model=current_model) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e00aaa4b97..63e85a6626 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1002,10 +1002,9 @@ def setup_model_provider(config: dict): min_key_ttl_seconds=5 * 60, timeout_seconds=15.0, ) - nous_models = fetch_nous_models( - inference_base_url=creds.get("base_url", ""), - api_key=creds.get("api_key", ""), - ) + # Use curated model list instead of full /models dump + from hermes_cli.models import _PROVIDER_MODELS + nous_models = _PROVIDER_MODELS.get("nous", []) except Exception as e: logger.debug("Could not fetch Nous models after login: %s", e) From b4ceb541a71e36314426a4040145f061dc125bb9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:51:44 -0700 Subject: [PATCH 02/52] fix(terminal): preserve partial output when command times out (#3868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a command timed out, all captured output was discarded — the agent only saw 'Command timed out after Xs' with zero context. Now returns the buffered output followed by a timeout marker, matching the existing interrupt path behavior. Salvaged from PR #3286 by @binhnt92. Co-authored-by: nguyen binh --- tests/tools/test_terminal_timeout_output.py | 27 +++++++++++++++++++++ tools/environments/local.py | 7 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_terminal_timeout_output.py diff --git a/tests/tools/test_terminal_timeout_output.py b/tests/tools/test_terminal_timeout_output.py new file mode 100644 index 0000000000..52823581fc --- /dev/null +++ b/tests/tools/test_terminal_timeout_output.py @@ -0,0 +1,27 @@ +"""Verify that terminal command timeouts preserve partial output.""" +from tools.environments.local import LocalEnvironment + + +class TestTimeoutPreservesPartialOutput: + """When a command times out, any output captured before the deadline + should be included in the result — not discarded.""" + + def test_timeout_includes_partial_output(self): + """A command that prints then sleeps past the deadline should + return both the printed text and the timeout notice.""" + env = LocalEnvironment() + result = env.execute("echo 'hello from test' && sleep 30", timeout=2) + + assert result["returncode"] == 124 + assert "hello from test" in result["output"] + assert "timed out" in result["output"].lower() + + def test_timeout_with_no_output(self): + """A command that produces nothing before timeout should still + return a clean timeout message.""" + env = LocalEnvironment() + result = env.execute("sleep 30", timeout=1) + + assert result["returncode"] == 124 + assert "timed out" in result["output"].lower() + assert not result["output"].startswith("\n") diff --git a/tools/environments/local.py b/tools/environments/local.py index 8cd416efaa..27282b6ef6 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -473,7 +473,12 @@ class LocalEnvironment(PersistentShellMixin, BaseEnvironment): except (ProcessLookupError, PermissionError): proc.kill() reader.join(timeout=2) - return self._timeout_result(effective_timeout) + partial = "".join(_output_chunks) + timeout_msg = f"\n[Command timed out after {effective_timeout}s]" + return { + "output": partial + timeout_msg if partial else timeout_msg.lstrip(), + "returncode": 124, + } time.sleep(0.2) reader.join(timeout=5) From 366bfc3c76e9fd43ab5195f4a8670928f0440bc9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:53:28 -0700 Subject: [PATCH 03/52] fix(setup): auto-install matrix-nio during hermes setup (#3873) Setup previously only printed a manual install hint for matrix-nio, causing the gateway to crash with 'matrix-nio not installed' after configuring Matrix. Now auto-installs matrix-nio (or matrix-nio[e2e] when E2EE is enabled) using the same uv-first/pip-fallback pattern as Daytona and Modal backends. Also adds hermes-agent[matrix] to the [all] extra in pyproject.toml and a regression test to keep it there. Co-authored-by: Gutslabs Co-authored-by: cutepawss --- hermes_cli/setup.py | 32 ++++++++++++++++++++++++++++++-- pyproject.toml | 1 + tests/test_project_metadata.py | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/test_project_metadata.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 63e85a6626..35695144d3 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2709,10 +2709,38 @@ def setup_gateway(config: dict): if token or get_env_value("MATRIX_PASSWORD"): # E2EE print() - if prompt_yes_no("Enable end-to-end encryption (E2EE)?", False): + want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False) + if want_e2ee: save_env_value("MATRIX_ENCRYPTION", "true") print_success("E2EE enabled") - print_info(" Requires: pip install 'matrix-nio[e2e]'") + + # Auto-install matrix-nio + matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio" + try: + __import__("nio") + except ImportError: + print_info(f"Installing {matrix_pkg}...") + import subprocess + + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", matrix_pkg], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success(f"{matrix_pkg} installed") + else: + print_warning(f"Install failed — run manually: pip install '{matrix_pkg}'") + if result.stderr: + print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") # Allowed users print() diff --git a/pyproject.toml b/pyproject.toml index ac410ff754..38974e3287 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ all = [ "hermes-agent[modal]", "hermes-agent[daytona]", "hermes-agent[messaging]", + "hermes-agent[matrix]", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[dev]", diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py new file mode 100644 index 0000000000..1a377f5f5e --- /dev/null +++ b/tests/test_project_metadata.py @@ -0,0 +1,18 @@ +"""Regression tests for packaging metadata in pyproject.toml.""" + +from pathlib import Path +import tomllib + + +def _load_optional_dependencies(): + pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" + with pyproject_path.open("rb") as handle: + project = tomllib.load(handle)["project"] + return project["optional-dependencies"] + + +def test_all_extra_includes_matrix_dependency(): + optional_dependencies = _load_optional_dependencies() + + assert "matrix" in optional_dependencies + assert "hermes-agent[matrix]" in optional_dependencies["all"] From 839f798b746770fcdd08608b6e3e49a2ede5b744 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:53:59 -0700 Subject: [PATCH 04/52] feat(telegram): add group mention gating and regex triggers (#3870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Discord-style mention gating for Telegram groups: - telegram.require_mention: gate group messages (default: false) - telegram.mention_patterns: regex wake-word triggers - telegram.free_response_chats: bypass gating for specific chats When require_mention is enabled, group messages are accepted only for: - slash commands - replies to the bot - @botusername mentions - regex wake-word pattern matches DMs remain unrestricted. @mention text is stripped before passing to the agent. Invalid regex patterns are ignored with a warning. Config bridges follow the existing Discord pattern (yaml → env vars). Cherry-picked and adapted from PR #1977 by mcleay. Fixed ChatType comparison to work without python-telegram-bot installed (uses string matching instead of enum, consistent with other entity_type checks). Co-authored-by: mcleay --- gateway/config.py | 18 ++ gateway/platforms/telegram.py | 155 +++++++++++++++++- tests/gateway/test_telegram_group_gating.py | 110 +++++++++++++ website/docs/user-guide/messaging/telegram.md | 30 +++- 4 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 tests/gateway/test_telegram_group_gating.py diff --git a/gateway/config.py b/gateway/config.py index a4ec65ccc9..c8ce89a7d6 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -515,6 +515,10 @@ def load_gateway_config() -> GatewayConfig: ) if "reply_prefix" in platform_cfg: bridged["reply_prefix"] = platform_cfg["reply_prefix"] + if "require_mention" in platform_cfg: + bridged["require_mention"] = platform_cfg["require_mention"] + if "mention_patterns" in platform_cfg: + bridged["mention_patterns"] = platform_cfg["mention_patterns"] if not bridged: continue plat_data = platforms_data.setdefault(plat.value, {}) @@ -539,6 +543,20 @@ def load_gateway_config() -> GatewayConfig: os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + + # Telegram settings → env vars (env vars take precedence) + telegram_cfg = yaml_cfg.get("telegram", {}) + if isinstance(telegram_cfg, dict): + if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"): + os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower() + if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"): + import json as _json + os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"]) + frc = telegram_cfg.get("free_response_chats") + if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) except Exception as e: logger.warning( "Failed to process config.yaml — falling back to .env / gateway.json values. " diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 5f497221fc..40aea55aba 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -8,6 +8,7 @@ Uses python-telegram-bot library for: """ import asyncio +import json import logging import os import re @@ -122,6 +123,7 @@ class TelegramAdapter(BasePlatformAdapter): super().__init__(config, Platform.TELEGRAM) self._app: Optional[Application] = None self._bot: Optional[Bot] = None + self._mention_patterns = self._compile_mention_patterns() self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' # Buffer rapid/album photo updates so Telegram image bursts are handled # as a single MessageEvent instead of self-interrupting multiple turns. @@ -1325,6 +1327,148 @@ class TelegramAdapter(BasePlatformAdapter): return text + # ── Group mention gating ────────────────────────────────────────────── + + def _telegram_require_mention(self) -> bool: + """Return whether group chats should require an explicit bot trigger.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in ("true", "1", "yes", "on") + return bool(configured) + return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + + def _telegram_free_response_chats(self) -> set[str]: + raw = self.config.extra.get("free_response_chats") + if raw is None: + raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + + def _compile_mention_patterns(self) -> List[re.Pattern]: + """Compile optional regex wake-word patterns for group triggers.""" + patterns = self.config.extra.get("mention_patterns") + if patterns is None: + raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip() + if raw: + try: + loaded = json.loads(raw) + except Exception: + loaded = [part.strip() for part in raw.splitlines() if part.strip()] + if not loaded: + loaded = [part.strip() for part in raw.split(",") if part.strip()] + patterns = loaded + + if patterns is None: + return [] + if isinstance(patterns, str): + patterns = [patterns] + if not isinstance(patterns, list): + logger.warning( + "[%s] telegram mention_patterns must be a list or string; got %s", + self.name, + type(patterns).__name__, + ) + return [] + + compiled: List[re.Pattern] = [] + for pattern in patterns: + if not isinstance(pattern, str) or not pattern.strip(): + continue + try: + compiled.append(re.compile(pattern, re.IGNORECASE)) + except re.error as exc: + logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc) + if compiled: + logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled)) + return compiled + + def _is_group_chat(self, message: Message) -> bool: + chat = getattr(message, "chat", None) + if not chat: + return False + chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() + return chat_type in ("group", "supergroup") + + def _is_reply_to_bot(self, message: Message) -> bool: + if not self._bot or not getattr(message, "reply_to_message", None): + return False + reply_user = getattr(message.reply_to_message, "from_user", None) + return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None)) + + def _message_mentions_bot(self, message: Message) -> bool: + if not self._bot: + return False + + bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower() + bot_id = getattr(self._bot, "id", None) + + def _iter_sources(): + yield getattr(message, "text", None) or "", getattr(message, "entities", None) or [] + yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or [] + + for source_text, entities in _iter_sources(): + if bot_username and f"@{bot_username}" in source_text.lower(): + return True + for entity in entities: + entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower() + if entity_type == "mention" and bot_username: + offset = int(getattr(entity, "offset", -1)) + length = int(getattr(entity, "length", 0)) + if offset < 0 or length <= 0: + continue + if source_text[offset:offset + length].strip().lower() == f"@{bot_username}": + return True + elif entity_type == "text_mention": + user = getattr(entity, "user", None) + if user and getattr(user, "id", None) == bot_id: + return True + return False + + def _message_matches_mention_patterns(self, message: Message) -> bool: + if not self._mention_patterns: + return False + for candidate in (getattr(message, "text", None), getattr(message, "caption", None)): + if not candidate: + continue + for pattern in self._mention_patterns: + if pattern.search(candidate): + return True + return False + + def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]: + if not text or not self._bot or not getattr(self._bot, "username", None): + return text + username = re.escape(self._bot.username) + cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip() + return cleaned or text + + def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool: + """Apply Telegram group trigger rules. + + DMs remain unrestricted. Group/supergroup messages are accepted when: + - the chat is explicitly allowlisted in ``free_response_chats`` + - ``require_mention`` is disabled + - the message is a command + - the message replies to the bot + - the bot is @mentioned + - the text/caption matches a configured regex wake-word pattern + """ + if not self._is_group_chat(message): + return True + if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats(): + return True + if not self._telegram_require_mention(): + return True + if is_command: + return True + if self._is_reply_to_bot(message): + return True + if self._message_mentions_bot(message): + return True + return self._message_matches_mention_patterns(message) + async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming text messages. @@ -1334,14 +1478,19 @@ class TelegramAdapter(BasePlatformAdapter): """ if not update.message or not update.message.text: return + if not self._should_process_message(update.message): + return event = self._build_message_event(update.message, MessageType.TEXT) + event.text = self._clean_bot_trigger_text(event.text) self._enqueue_text_event(event) async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming command messages.""" if not update.message or not update.message.text: return + if not self._should_process_message(update.message, is_command=True): + return event = self._build_message_event(update.message, MessageType.COMMAND) await self.handle_message(event) @@ -1350,6 +1499,8 @@ class TelegramAdapter(BasePlatformAdapter): """Handle incoming location/venue pin messages.""" if not update.message: return + if not self._should_process_message(update.message): + return msg = update.message venue = getattr(msg, "venue", None) @@ -1493,6 +1644,8 @@ class TelegramAdapter(BasePlatformAdapter): """Handle incoming media messages, downloading images to local cache.""" if not update.message: return + if not self._should_process_message(update.message): + return msg = update.message @@ -1516,7 +1669,7 @@ class TelegramAdapter(BasePlatformAdapter): # Add caption as text if msg.caption: - event.text = msg.caption + event.text = self._clean_bot_trigger_text(msg.caption) # Handle stickers: describe via vision tool with caching if msg.sticker: diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py new file mode 100644 index 0000000000..99675605d0 --- /dev/null +++ b/tests/gateway/test_telegram_group_gating.py @@ -0,0 +1,110 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from gateway.config import Platform, PlatformConfig, load_gateway_config + + +def _make_adapter(require_mention=None, free_response_chats=None, mention_patterns=None): + from gateway.platforms.telegram import TelegramAdapter + + extra = {} + if require_mention is not None: + extra["require_mention"] = require_mention + if free_response_chats is not None: + extra["free_response_chats"] = free_response_chats + if mention_patterns is not None: + extra["mention_patterns"] = mention_patterns + + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter.config = PlatformConfig(enabled=True, token="***", extra=extra) + adapter._bot = SimpleNamespace(id=999, username="hermes_bot") + adapter._message_handler = AsyncMock() + adapter._pending_text_batches = {} + adapter._pending_text_batch_tasks = {} + adapter._text_batch_delay_seconds = 0.01 + adapter._mention_patterns = adapter._compile_mention_patterns() + return adapter + + +def _group_message(text="hello", *, chat_id=-100, reply_to_bot=False, entities=None, caption=None, caption_entities=None): + reply_to_message = None + if reply_to_bot: + reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999)) + return SimpleNamespace( + text=text, + caption=caption, + entities=entities or [], + caption_entities=caption_entities or [], + chat=SimpleNamespace(id=chat_id, type="group"), + reply_to_message=reply_to_message, + ) + + +def _mention_entity(text, mention="@hermes_bot"): + offset = text.index(mention) + return SimpleNamespace(type="mention", offset=offset, length=len(mention)) + + +def test_group_messages_can_be_opened_via_config(): + adapter = _make_adapter(require_mention=False) + + assert adapter._should_process_message(_group_message("hello everyone")) is True + + +def test_group_messages_can_require_direct_trigger_via_config(): + adapter = _make_adapter(require_mention=True) + + assert adapter._should_process_message(_group_message("hello everyone")) is False + assert adapter._should_process_message(_group_message("hi @hermes_bot", entities=[_mention_entity("hi @hermes_bot")])) is True + assert adapter._should_process_message(_group_message("replying", reply_to_bot=True)) is True + assert adapter._should_process_message(_group_message("/status"), is_command=True) is True + + +def test_free_response_chats_bypass_mention_requirement(): + adapter = _make_adapter(require_mention=True, free_response_chats=["-200"]) + + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-200)) is True + assert adapter._should_process_message(_group_message("hello everyone", chat_id=-201)) is False + + +def test_regex_mention_patterns_allow_custom_wake_words(): + adapter = _make_adapter(require_mention=True, mention_patterns=[r"^\s*chompy\b"]) + + assert adapter._should_process_message(_group_message("chompy status")) is True + assert adapter._should_process_message(_group_message(" chompy help")) is True + assert adapter._should_process_message(_group_message("hey chompy")) is False + + +def test_invalid_regex_patterns_are_ignored(): + adapter = _make_adapter(require_mention=True, mention_patterns=[r"(", r"^\s*chompy\b"]) + + assert adapter._should_process_message(_group_message("chompy status")) is True + assert adapter._should_process_message(_group_message("hello everyone")) is False + + +def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "telegram:\n" + " require_mention: true\n" + " mention_patterns:\n" + " - \"^\\\\s*chompy\\\\b\"\n" + " free_response_chats:\n" + " - \"-123\"\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False) + monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False) + + config = load_gateway_config() + + assert config is not None + assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true" + assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"] + assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123" diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 794845effb..be99eaa757 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -161,9 +161,35 @@ Configure the TTS provider in your `config.yaml` under the `tts.provider` key. Hermes Agent works in Telegram group chats with a few considerations: - **Privacy mode** determines what messages the bot can see (see [Step 3](#step-3-privacy-mode-critical-for-groups)) -- When privacy mode is on, **@mention the bot** (e.g., `@my_hermes_bot what's the weather?`) or **reply to its messages** to interact -- When privacy mode is off (or bot is admin), the bot sees all messages and can participate naturally - `TELEGRAM_ALLOWED_USERS` still applies — only authorized users can trigger the bot, even in groups +- You can keep the bot from responding to ordinary group chatter with `telegram.require_mention: true` +- With `telegram.require_mention: true`, group messages are accepted when they are: + - slash commands + - replies to one of the bot's messages + - `@botusername` mentions + - matches for one of your configured regex wake words in `telegram.mention_patterns` +- If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see + +### Example group trigger configuration + +Add this to `~/.hermes/config.yaml`: + +```yaml +telegram: + require_mention: true + mention_patterns: + - "^\\s*chompy\\b" +``` + +This example allows all the usual direct triggers plus messages that begin with `chompy`, even if they do not use an `@mention`. + +### Notes on `mention_patterns` + +- Patterns use Python regular expressions +- Matching is case-insensitive +- Patterns are checked against both text messages and media captions +- Invalid regex patterns are ignored with a warning in the gateway logs rather than crashing the bot +- If you want a pattern to match only at the start of a message, anchor it with `^` ## Private Chat Topics (Bot API 9.4) From fd29933a6d44fdb1168fed5a50df07aba84c8387 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:54:36 -0700 Subject: [PATCH 05/52] fix: use argparse entrypoint in top-level launcher (#3874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ./hermes convenience script still used the legacy Fire-based cli.main wrapper, which doesn't support subcommands (gateway, cron, doctor, etc.). The installed 'hermes' command already uses hermes_cli.main:main (argparse) — this aligns the launcher. Salvaged from PR #2009 by gito369. --- hermes | 11 ++++---- tests/hermes_cli/test_launcher.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 tests/hermes_cli/test_launcher.py diff --git a/hermes b/hermes index f0feeb2bad..3172ca91ca 100755 --- a/hermes +++ b/hermes @@ -1,12 +1,11 @@ #!/usr/bin/env python3 """ -Hermes Agent CLI Launcher +Hermes Agent CLI launcher. -This is a convenience wrapper to launch the Hermes CLI. -Usage: ./hermes [options] +This wrapper should behave like the installed `hermes` command, including +subcommands such as `gateway`, `cron`, and `doctor`. """ if __name__ == "__main__": - from cli import main - import fire - fire.Fire(main) + from hermes_cli.main import main + main() diff --git a/tests/hermes_cli/test_launcher.py b/tests/hermes_cli/test_launcher.py new file mode 100644 index 0000000000..9c3cea851f --- /dev/null +++ b/tests/hermes_cli/test_launcher.py @@ -0,0 +1,42 @@ +"""Tests for the top-level `./hermes` launcher script.""" + +import runpy +import sys +import types +from pathlib import Path + + +def test_launcher_delegates_to_argparse_entrypoint(monkeypatch): + """`./hermes` should use `hermes_cli.main`, not the legacy Fire wrapper.""" + launcher_path = Path(__file__).resolve().parents[2] / "hermes" + called = [] + + fake_main_module = types.ModuleType("hermes_cli.main") + + def fake_main(): + called.append("hermes_cli.main") + + fake_main_module.main = fake_main + monkeypatch.setitem(sys.modules, "hermes_cli.main", fake_main_module) + + fake_cli_module = types.ModuleType("cli") + + def legacy_cli_main(*args, **kwargs): + raise AssertionError("launcher should not import cli.main") + + fake_cli_module.main = legacy_cli_main + monkeypatch.setitem(sys.modules, "cli", fake_cli_module) + + fake_fire_module = types.ModuleType("fire") + + def legacy_fire(*args, **kwargs): + raise AssertionError("launcher should not invoke fire.Fire") + + fake_fire_module.Fire = legacy_fire + monkeypatch.setitem(sys.modules, "fire", fake_fire_module) + + monkeypatch.setattr(sys, "argv", [str(launcher_path), "gateway", "status"]) + + runpy.run_path(str(launcher_path), run_name="__main__") + + assert called == ["hermes_cli.main"] From 227601c20067250f0b12890385153a489b1b6f42 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:55:23 -0700 Subject: [PATCH 06/52] feat(discord): add message processing reactions (salvage #1980) (#3871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lifecycle hooks to the base platform adapter so Discord (and future platforms) can react to message processing events: 👀 when processing starts ✅ on successful completion (delivery confirmed) ❌ on failure, error, or cancellation Implementation: - base.py: on_processing_start/on_processing_complete hooks with _run_processing_hook error isolation wrapper; delivery tracking via _record_delivery closure for accurate success detection - discord.py: _add_reaction/_remove_reaction helpers + hook overrides - Tests for base hook lifecycle and Discord-specific reactions Co-authored-by: alanwilhelm --- gateway/platforms/base.py | 43 ++++++ gateway/platforms/discord.py | 35 +++++ tests/gateway/test_base_topic_sessions.py | 87 +++++++++++ tests/gateway/test_discord_reactions.py | 170 ++++++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 tests/gateway/test_discord_reactions.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index efa5ed3184..9a821727ed 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -898,6 +898,26 @@ class BasePlatformAdapter(ABC): except Exception: pass + # ── Processing lifecycle hooks ────────────────────────────────────────── + # Subclasses override these to react to message processing events + # (e.g. Discord adds 👀/✅/❌ reactions). + + async def on_processing_start(self, event: MessageEvent) -> None: + """Hook called when background processing begins.""" + + async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: + """Hook called when background processing completes.""" + + async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None: + """Run a lifecycle hook without letting failures break message flow.""" + hook = getattr(self, hook_name, None) + if not callable(hook): + return + try: + await hook(*args, **kwargs) + except Exception as e: + logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e) + @staticmethod def _is_retryable_error(error: Optional[str]) -> bool: """Return True if the error string looks like a transient network failure.""" @@ -1060,6 +1080,18 @@ class BasePlatformAdapter(ABC): async def _process_message_background(self, event: MessageEvent, session_key: str) -> None: """Background task that actually processes the message.""" + # Track delivery outcomes for the processing-complete hook + delivery_attempted = False + delivery_succeeded = False + + def _record_delivery(result): + nonlocal delivery_attempted, delivery_succeeded + if result is None: + return + delivery_attempted = True + if getattr(result, "success", False): + delivery_succeeded = True + # Create interrupt event for this session interrupt_event = asyncio.Event() self._active_sessions[session_key] = interrupt_event @@ -1069,6 +1101,8 @@ class BasePlatformAdapter(ABC): typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata)) try: + await self._run_processing_hook("on_processing_start", event) + # Call the handler (this can take a while with tool calls) response = await self._message_handler(event) @@ -1138,6 +1172,7 @@ class BasePlatformAdapter(ABC): reply_to=event.message_id, metadata=_thread_metadata, ) + _record_delivery(result) # Human-like pacing delay between text and media human_delay = self._get_human_delay() @@ -1237,6 +1272,10 @@ class BasePlatformAdapter(ABC): except Exception as file_err: logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err) + # Determine overall success for the processing hook + processing_ok = delivery_succeeded if delivery_attempted else not bool(response) + await self._run_processing_hook("on_processing_complete", event, processing_ok) + # Check if there's a pending message that was queued during our processing if session_key in self._pending_messages: pending_event = self._pending_messages.pop(session_key) @@ -1253,7 +1292,11 @@ class BasePlatformAdapter(ABC): await self._process_message_background(pending_event, session_key) return # Already cleaned up + except asyncio.CancelledError: + await self._run_processing_hook("on_processing_complete", event, False) + raise except Exception as e: + await self._run_processing_hook("on_processing_complete", event, False) logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True) # Send the error to the user so they aren't left with radio silence try: diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 1da9925cd5..9e0c9c123e 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -660,6 +660,41 @@ class DiscordAdapter(BasePlatformAdapter): pass logger.info("[%s] Disconnected", self.name) + + async def _add_reaction(self, message: Any, emoji: str) -> bool: + """Add an emoji reaction to a Discord message.""" + if not message or not hasattr(message, "add_reaction"): + return False + try: + await message.add_reaction(emoji) + return True + except Exception as e: + logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e) + return False + + async def _remove_reaction(self, message: Any, emoji: str) -> bool: + """Remove the bot's own emoji reaction from a Discord message.""" + if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user: + return False + try: + await message.remove_reaction(emoji, self._client.user) + return True + except Exception as e: + logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e) + return False + + async def on_processing_start(self, event: MessageEvent) -> None: + """Add an in-progress reaction for normal Discord message events.""" + message = event.raw_message + if hasattr(message, "add_reaction"): + await self._add_reaction(message, "👀") + + async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: + """Swap the in-progress reaction for a final success/failure reaction.""" + message = event.raw_message + if hasattr(message, "add_reaction"): + await self._remove_reaction(message, "👀") + await self._add_reaction(message, "✅" if success else "❌") async def send( self, diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py index e3ca7ae722..37e00b279d 100644 --- a/tests/gateway/test_base_topic_sessions.py +++ b/tests/gateway/test_base_topic_sessions.py @@ -15,6 +15,7 @@ class DummyTelegramAdapter(BasePlatformAdapter): super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.TELEGRAM) self.sent = [] self.typing = [] + self.processing_hooks = [] async def connect(self) -> bool: return True @@ -40,6 +41,12 @@ class DummyTelegramAdapter(BasePlatformAdapter): async def get_chat_info(self, chat_id: str): return {"id": chat_id} + async def on_processing_start(self, event: MessageEvent) -> None: + self.processing_hooks.append(("start", event.message_id)) + + async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: + self.processing_hooks.append(("complete", event.message_id, success)) + def _make_event(chat_id: str, thread_id: str, message_id: str = "1") -> MessageEvent: return MessageEvent( @@ -133,3 +140,83 @@ class TestBasePlatformTopicSessions: "metadata": {"thread_id": "17585"}, } ] + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", True), + ] + + @pytest.mark.asyncio + async def test_process_message_background_marks_total_send_failure_unsuccessful(self): + adapter = DummyTelegramAdapter() + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def failing_send(*_args, **_kwargs): + return SendResult(success=False, error="send failed") + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = failing_send + adapter._keep_typing = hold_typing + + event = _make_event("-1001", "17585") + await adapter._process_message_background(event, build_session_key(event.source)) + + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", False), + ] + + @pytest.mark.asyncio + async def test_process_message_background_marks_exception_unsuccessful(self): + adapter = DummyTelegramAdapter() + + async def handler(_event): + await asyncio.sleep(0) + raise RuntimeError("boom") + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter._keep_typing = hold_typing + + event = _make_event("-1001", "17585") + await adapter._process_message_background(event, build_session_key(event.source)) + + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", False), + ] + + @pytest.mark.asyncio + async def test_process_message_background_marks_cancellation_unsuccessful(self): + adapter = DummyTelegramAdapter() + release = asyncio.Event() + + async def handler(_event): + await release.wait() + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter._keep_typing = hold_typing + + event = _make_event("-1001", "17585") + task = asyncio.create_task(adapter._process_message_background(event, build_session_key(event.source))) + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert adapter.processing_hooks == [ + ("start", "1"), + ("complete", "1", False), + ] diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py new file mode 100644 index 0000000000..c19913a4cf --- /dev/null +++ b/tests/gateway/test_discord_reactions.py @@ -0,0 +1,170 @@ +"""Tests for Discord message reactions tied to processing lifecycle hooks.""" + +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.session import SessionSource, build_session_key + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +class FakeTree: + def __init__(self): + self.commands = {} + + def command(self, *, name, description): + def decorator(fn): + self.commands[name] = fn + return fn + + return decorator + + +@pytest.fixture +def adapter(): + config = PlatformConfig(enabled=True, token="***") + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace( + tree=FakeTree(), + get_channel=lambda _id: None, + fetch_channel=AsyncMock(), + user=SimpleNamespace(id=99999, name="HermesBot"), + ) + return adapter + + +def _make_event(message_id: str, raw_message) -> MessageEvent: + return MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.DISCORD, + chat_id="123", + chat_type="dm", + user_id="42", + user_name="Jezza", + ), + raw_message=raw_message, + message_id=message_id, + ) + + +@pytest.mark.asyncio +async def test_process_message_background_adds_and_swaps_reactions(adapter): + raw_message = SimpleNamespace( + add_reaction=AsyncMock(), + remove_reaction=AsyncMock(), + ) + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999")) + adapter._keep_typing = hold_typing + + event = _make_event("1", raw_message) + await adapter._process_message_background(event, build_session_key(event.source)) + + assert raw_message.add_reaction.await_args_list[0].args == ("👀",) + assert raw_message.remove_reaction.await_args_list[0].args == ("👀", adapter._client.user) + assert raw_message.add_reaction.await_args_list[1].args == ("✅",) + + +@pytest.mark.asyncio +async def test_interaction_backed_events_do_not_attempt_reactions(adapter): + interaction = SimpleNamespace(guild_id=123456789) + + async def handler(_event): + await asyncio.sleep(0) + return None + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter._add_reaction = AsyncMock() + adapter._remove_reaction = AsyncMock() + adapter._keep_typing = hold_typing + + event = MessageEvent( + text="/status", + message_type=MessageType.COMMAND, + source=SessionSource( + platform=Platform.DISCORD, + chat_id="123", + chat_type="dm", + user_id="42", + user_name="Jezza", + ), + raw_message=interaction, + message_id="2", + ) + + await adapter._process_message_background(event, build_session_key(event.source)) + + adapter._add_reaction.assert_not_awaited() + adapter._remove_reaction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reaction_helper_failures_do_not_break_message_flow(adapter): + raw_message = SimpleNamespace( + add_reaction=AsyncMock(side_effect=[RuntimeError("no perms"), RuntimeError("no perms")]), + remove_reaction=AsyncMock(side_effect=RuntimeError("no perms")), + ) + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999")) + adapter._keep_typing = hold_typing + + event = _make_event("3", raw_message) + await adapter._process_message_background(event, build_session_key(event.source)) + + adapter.send.assert_awaited_once() From 1c900c45e314c6b9580f5f05bc6e3ba967243635 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:56:07 -0700 Subject: [PATCH 07/52] fix(agent): support full context length resolution for direct Gemini API endpoints (#3876) * add .aac audio file format support to transcription tool * fix(agent): support full context length resolution for direct Gemini API endpoints Add generativelanguage.googleapis.com to _URL_TO_PROVIDER so direct Gemini API users get correct 1M+ context length instead of the 128K unknown-proxy fallback. Co-authored-by: bb873 --------- Co-authored-by: Adrian Scott Co-authored-by: bb873 --- agent/model_metadata.py | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 162295f81d..0c121e6f63 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -171,6 +171,7 @@ _URL_TO_PROVIDER: Dict[str, str] = { "dashscope.aliyuncs.com": "alibaba", "dashscope-intl.aliyuncs.com": "alibaba", "openrouter.ai": "openrouter", + "generativelanguage.googleapis.com": "google", "inference-api.nousresearch.com": "nous", "api.deepseek.com": "deepseek", "api.githubcopilot.com": "copilot", From 560245879417625e41e416300b8a78d5d727c6f1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:33:47 -0700 Subject: [PATCH 08/52] security: harden dangerous command detection and add file tool path guards (#3872) Closes gaps that allowed an agent to expose Docker's Remote API to the internet by writing to /etc/docker/daemon.json. Terminal tool (approval.py): - chmod: now catches 666 and symbolic modes (o+w, a+w), not just 777 - cp/mv/install: detected when targeting /etc/ - sed -i/--in-place: detected when targeting /etc/ File tools (file_tools.py): - write_file and patch now refuse to write to sensitive system paths (/etc/, /boot/, /usr/lib/systemd/, docker.sock) - Directs users to the terminal tool (which has approval prompts) for system file modifications --- tools/approval.py | 8 ++++++-- tools/file_tools.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/tools/approval.py b/tools/approval.py index 8ae52407f9..e7313b002f 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -41,8 +41,8 @@ DANGEROUS_PATTERNS = [ (r'\brm\s+(-[^\s]*\s+)*/', "delete in root path"), (r'\brm\s+-[^\s]*r', "recursive delete"), (r'\brm\s+--recursive\b', "recursive delete (long flag)"), - (r'\bchmod\s+(-[^\s]*\s+)*777\b', "world-writable permissions"), - (r'\bchmod\s+--recursive\b.*777', "recursive world-writable (long flag)"), + (r'\bchmod\s+(-[^\s]*\s+)*(777|666|o\+[rwx]*w|a\+[rwx]*w)\b', "world/other-writable permissions"), + (r'\bchmod\s+--recursive\b.*(777|666|o\+[rwx]*w|a\+[rwx]*w)', "recursive world/other-writable (long flag)"), (r'\bchown\s+(-[^\s]*)?R\s+root', "recursive chown to root"), (r'\bchown\s+--recursive\b.*root', "recursive chown to root (long flag)"), (r'\bmkfs\b', "format filesystem"), @@ -71,6 +71,10 @@ DANGEROUS_PATTERNS = [ (r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"), # Self-termination protection: prevent agent from killing its own process (r'\b(pkill|killall)\b.*\b(hermes|gateway|cli\.py)\b', "kill hermes/gateway process (self-termination)"), + # File copy/move/edit into sensitive system paths + (r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"), + (r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"), + (r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"), ] diff --git a/tools/file_tools.py b/tools/file_tools.py index 7387c4dcbc..6226e76574 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -4,7 +4,9 @@ import errno import json import logging +import os import threading +from pathlib import Path from tools.file_operations import ShellFileOperations from agent.redact import redact_sensitive_text @@ -13,6 +15,31 @@ logger = logging.getLogger(__name__) _EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS} +# Paths that file tools should refuse to write to without going through the +# terminal tool's approval system. These match prefixes after os.path.realpath. +_SENSITIVE_PATH_PREFIXES = ("/etc/", "/boot/", "/usr/lib/systemd/") +_SENSITIVE_EXACT_PATHS = {"/var/run/docker.sock", "/run/docker.sock"} + + +def _check_sensitive_path(filepath: str) -> str | None: + """Return an error message if the path targets a sensitive system location.""" + try: + resolved = os.path.realpath(os.path.expanduser(filepath)) + except (OSError, ValueError): + resolved = filepath + for prefix in _SENSITIVE_PATH_PREFIXES: + if resolved.startswith(prefix): + return ( + f"Refusing to write to sensitive system path: {filepath}\n" + "Use the terminal tool with sudo if you need to modify system files." + ) + if resolved in _SENSITIVE_EXACT_PATHS: + return ( + f"Refusing to write to sensitive system path: {filepath}\n" + "Use the terminal tool with sudo if you need to modify system files." + ) + return None + def _is_expected_write_exception(exc: Exception) -> bool: """Return True for expected write denials that should not hit error logs.""" @@ -287,6 +314,9 @@ def notify_other_tool_call(task_id: str = "default"): def write_file_tool(path: str, content: str, task_id: str = "default") -> str: """Write content to a file.""" + sensitive_err = _check_sensitive_path(path) + if sensitive_err: + return json.dumps({"error": sensitive_err}, ensure_ascii=False) try: file_ops = _get_file_ops(task_id) result = file_ops.write_file(path, content) @@ -303,6 +333,18 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, new_string: str = None, replace_all: bool = False, patch: str = None, task_id: str = "default") -> str: """Patch a file using replace mode or V4A patch format.""" + # Check sensitive paths for both replace (explicit path) and V4A patch (extract paths) + _paths_to_check = [] + if path: + _paths_to_check.append(path) + if mode == "patch" and patch: + import re as _re + for _m in _re.finditer(r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', patch, _re.MULTILINE): + _paths_to_check.append(_m.group(1).strip()) + for _p in _paths_to_check: + sensitive_err = _check_sensitive_path(_p) + if sensitive_err: + return json.dumps({"error": sensitive_err}, ensure_ascii=False) try: file_ops = _get_file_ops(task_id) From 649d149438eadf72ce3f9cac56177796f338a5d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:36:07 -0700 Subject: [PATCH 09/52] feat(telegram): add webhook mode as alternative to polling (#3880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When TELEGRAM_WEBHOOK_URL is set, the adapter starts an HTTP webhook server (via python-telegram-bot's start_webhook()) instead of long polling. This enables cloud platforms like Fly.io and Railway to auto-wake suspended machines on inbound HTTP traffic. Polling remains the default — no behavior change unless the env var is set. Env vars: TELEGRAM_WEBHOOK_URL Public HTTPS URL for Telegram to push to TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) TELEGRAM_WEBHOOK_SECRET Secret token for update verification Cherry-picked and adapted from PR #2022 by SHL0MS. Preserved all current main enhancements (network error recovery, polling conflict detection, DM topics setup). Co-authored-by: SHL0MS --- gateway/platforms/telegram.py | 86 ++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 40aea55aba..e17d104a68 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -123,6 +123,7 @@ class TelegramAdapter(BasePlatformAdapter): super().__init__(config, Platform.TELEGRAM) self._app: Optional[Application] = None self._bot: Optional[Bot] = None + self._webhook_mode: bool = False self._mention_patterns = self._compile_mention_patterns() self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' # Buffer rapid/album photo updates so Telegram image bursts are handled @@ -458,7 +459,19 @@ class TelegramAdapter(BasePlatformAdapter): self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id) async def connect(self) -> bool: - """Connect to Telegram and start polling for updates.""" + """Connect to Telegram via polling or webhook. + + By default, uses long polling (outbound connection to Telegram). + If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server + instead. Webhook mode is useful for cloud deployments (Fly.io, + Railway) where inbound HTTP can wake a suspended machine. + + Env vars for webhook mode:: + + TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram) + TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) + TELEGRAM_WEBHOOK_SECRET Secret token for update verification + """ if not TELEGRAM_AVAILABLE: logger.error( "[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot", @@ -552,27 +565,57 @@ class TelegramAdapter(BasePlatformAdapter): else: raise await self._app.start() - loop = asyncio.get_running_loop() - def _polling_error_callback(error: Exception) -> None: - if self._polling_error_task and not self._polling_error_task.done(): - return - if self._looks_like_polling_conflict(error): - self._polling_error_task = loop.create_task(self._handle_polling_conflict(error)) - elif self._looks_like_network_error(error): - logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error) - self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) - else: - logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True) + # Decide between webhook and polling mode + webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() - # Store reference for retry use in _handle_polling_conflict - self._polling_error_callback_ref = _polling_error_callback + if webhook_url: + # ── Webhook mode ───────────────────────────────────── + # Telegram pushes updates to our HTTP endpoint. This + # enables cloud platforms (Fly.io, Railway) to auto-wake + # suspended machines on inbound HTTP traffic. + webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443")) + webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None + from urllib.parse import urlparse + webhook_path = urlparse(webhook_url).path or "/telegram" - await self._app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, - drop_pending_updates=True, - error_callback=_polling_error_callback, - ) + await self._app.updater.start_webhook( + listen="0.0.0.0", + port=webhook_port, + url_path=webhook_path, + webhook_url=webhook_url, + secret_token=webhook_secret, + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True, + ) + self._webhook_mode = True + logger.info( + "[%s] Webhook server listening on 0.0.0.0:%d%s", + self.name, webhook_port, webhook_path, + ) + else: + # ── Polling mode (default) ─────────────────────────── + loop = asyncio.get_running_loop() + + def _polling_error_callback(error: Exception) -> None: + if self._polling_error_task and not self._polling_error_task.done(): + return + if self._looks_like_polling_conflict(error): + self._polling_error_task = loop.create_task(self._handle_polling_conflict(error)) + elif self._looks_like_network_error(error): + logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error) + self._polling_error_task = loop.create_task(self._handle_polling_network_error(error)) + else: + logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True) + + # Store reference for retry use in _handle_polling_conflict + self._polling_error_callback_ref = _polling_error_callback + + await self._app.updater.start_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True, + error_callback=_polling_error_callback, + ) # Register bot commands so Telegram shows a hint menu when users type / # List is derived from the central COMMAND_REGISTRY — adding a new @@ -592,7 +635,8 @@ class TelegramAdapter(BasePlatformAdapter): ) self._mark_connected() - logger.info("[%s] Connected and polling for Telegram updates", self.name) + mode = "webhook" if self._webhook_mode else "polling" + logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode) # Set up DM topics (Bot API 9.4 — Private Chat Topics) # Runs after connection is established so the bot can call createForumTopic. @@ -620,7 +664,7 @@ class TelegramAdapter(BasePlatformAdapter): return False async def disconnect(self) -> None: - """Stop polling, cancel pending album flushes, and disconnect.""" + """Stop polling/webhook, cancel pending album flushes, and disconnect.""" pending_media_group_tasks = list(self._media_group_tasks.values()) for task in pending_media_group_tasks: task.cancel() From 09def65effe9ceed781a5e33e8948538a656a26b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:49:34 -0700 Subject: [PATCH 10/52] fix(migration): expand OpenClaw migration to cover full data footprint (#3869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-referenced the OpenClaw Zod schema and TypeScript source against our migration script. Found and fixed: Expanded data sources: - Legacy config fallback: clawdbot.json, moldbot.json - Legacy dir fallback: ~/.clawdbot/, ~/.moldbot/ - API keys from ~/.openclaw/.env and auth-profiles.json - Personal skills from ~/.agents/skills/ - Project skills from workspace/.agents/skills/ - BOOTSTRAP.md archived (was silently skipped) - Expanded env key allowlist: DEEPSEEK, GEMINI, ZAI, MINIMAX Fixed wrong config paths (verified against Zod schema): - humanDelay.enabled → humanDelay.mode (field doesn't exist as .enabled) - agents.defaults.exec.timeout → tools.exec.timeoutSec (wrong path + name) - messages.tts.elevenlabs.voiceId → messages.tts.providers.elevenlabs.voiceId - session.resetTriggers (string[]) → session.reset (structured object) - approvals.mode → approvals.exec.mode (no top-level mode) - browser.inactivityTimeoutMs → doesn't exist; map cdpUrl+headless instead - tools.webSearch.braveApiKey → tools.web.search.brave.apiKey - tools.exec.timeout → tools.exec.timeoutSec Added SecretRef resolution: - All token/apiKey fields in OpenClaw can be strings, env templates (${VAR}), or SecretRef objects ({source:'env',id:'VAR'}). Added resolve_secret_input() to handle all three forms. Fixed auth-profiles.json: - Canonical field is 'key' not 'apiKey' (though alias accepted) - File wraps entries in a 'profiles' key — now handled Fixed TTS config: - Provider settings at messages.tts.providers.{name} (not flat) - Also checks top-level 'talk' config as fallback source Docs updated with new sources and key list. --- hermes_cli/claw.py | 14 +- .../scripts/openclaw_to_hermes.py | 259 +++++++++++++----- website/docs/reference/cli-commands.md | 8 +- 3 files changed, 216 insertions(+), 65 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 97e1acc406..014a2abeb4 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -88,7 +88,19 @@ def claw_command(args): def _cmd_migrate(args): """Run the OpenClaw → Hermes migration.""" - source_dir = Path(getattr(args, "source", None) or Path.home() / ".openclaw") + # Check current and legacy OpenClaw directories + explicit_source = getattr(args, "source", None) + if explicit_source: + source_dir = Path(explicit_source) + else: + source_dir = Path.home() / ".openclaw" + if not source_dir.is_dir(): + # Try legacy directory names + for legacy in (".clawdbot", ".moldbot"): + candidate = Path.home() / legacy + if candidate.is_dir(): + source_dir = candidate + break dry_run = getattr(args, "dry_run", False) preset = getattr(args, "preset", "full") overwrite = getattr(args, "overwrite", False) diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index f607ee56ba..f2e3d7af68 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -304,6 +304,29 @@ def ensure_parent(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) +def resolve_secret_input(value: Any, env: Optional[Dict[str, str]] = None) -> Optional[str]: + """Resolve an OpenClaw SecretInput value to a plain string. + + SecretInput can be: + - A plain string: "sk-..." + - An env template: "${OPENROUTER_API_KEY}" + - A SecretRef object: {"source": "env", "id": "OPENROUTER_API_KEY"} + """ + if isinstance(value, str): + # Check for env template: "${VAR_NAME}" + m = re.match(r"^\$\{(\w+)\}$", value.strip()) + if m and env: + return env.get(m.group(1), "").strip() or None + return value.strip() or None + if isinstance(value, dict): + source = value.get("source", "") + ref_id = value.get("id", "") + if source == "env" and ref_id and env: + return env.get(ref_id, "").strip() or None + # File/exec sources can't be resolved here — return None + return None + + def load_yaml_file(path: Path) -> Dict[str, Any]: if yaml is None or not path.exists(): return {} @@ -890,14 +913,20 @@ class Migrator: self.record("command-allowlist", source, destination, "migrated", "Would merge patterns", added_patterns=added) def load_openclaw_config(self) -> Dict[str, Any]: - config_path = self.source_root / "openclaw.json" - if not config_path.exists(): - return {} - try: - data = json.loads(config_path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else {} - except json.JSONDecodeError: - return {} + # Check current name and legacy config filenames + for name in ("openclaw.json", "clawdbot.json", "moldbot.json"): + config_path = self.source_root / name + if config_path.exists(): + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except json.JSONDecodeError: + continue + return {} + + def load_openclaw_env(self) -> Dict[str, str]: + """Load the OpenClaw .env file for secrets that live there instead of config.""" + return parse_env_file(self.source_root / ".env") def merge_env_values(self, additions: Dict[str, str], kind: str, source: Path) -> None: destination = self.target_root / ".env" @@ -1024,6 +1053,10 @@ class Migrator: supported_targets=sorted(SUPPORTED_SECRET_TARGETS), ) + def _resolve_channel_secret(self, value: Any) -> Optional[str]: + """Resolve a channel config value that may be a SecretRef.""" + return resolve_secret_input(value, self.load_openclaw_env()) + def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() additions: Dict[str, str] = {} @@ -1118,15 +1151,17 @@ class Migrator: secret_additions: Dict[str, str] = {} # Extract provider API keys from models.providers + # Note: apiKey values can be strings, env templates, or SecretRef objects + openclaw_env = self.load_openclaw_env() providers = config.get("models", {}).get("providers", {}) if isinstance(providers, dict): for provider_name, provider_cfg in providers.items(): if not isinstance(provider_cfg, dict): continue - api_key = provider_cfg.get("apiKey") - if not isinstance(api_key, str) or not api_key.strip(): + raw_key = provider_cfg.get("apiKey") + api_key = resolve_secret_input(raw_key, openclaw_env) + if not api_key: continue - api_key = api_key.strip() base_url = provider_cfg.get("baseUrl", "") api_type = provider_cfg.get("api", "") @@ -1170,6 +1205,50 @@ class Migrator: if isinstance(oai_key, str) and oai_key.strip(): secret_additions["VOICE_TOOLS_OPENAI_KEY"] = oai_key.strip() + # Also check the OpenClaw .env file — many users store keys there + # instead of inline in openclaw.json + openclaw_env = self.load_openclaw_env() + env_key_mapping = { + "OPENROUTER_API_KEY": "OPENROUTER_API_KEY", + "OPENAI_API_KEY": "OPENAI_API_KEY", + "ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY", + "ELEVENLABS_API_KEY": "ELEVENLABS_API_KEY", + "TELEGRAM_BOT_TOKEN": "TELEGRAM_BOT_TOKEN", + "DEEPSEEK_API_KEY": "DEEPSEEK_API_KEY", + "GEMINI_API_KEY": "GEMINI_API_KEY", + "ZAI_API_KEY": "ZAI_API_KEY", + "MINIMAX_API_KEY": "MINIMAX_API_KEY", + } + for oc_key, hermes_key in env_key_mapping.items(): + val = openclaw_env.get(oc_key, "").strip() + if val and hermes_key not in secret_additions: + secret_additions[hermes_key] = val + + # Check per-agent auth-profiles.json for additional credentials + auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json" + if auth_profiles_path.exists(): + try: + profiles = json.loads(auth_profiles_path.read_text(encoding="utf-8")) + if isinstance(profiles, dict): + # auth-profiles.json wraps profiles in a "profiles" key + profile_entries = profiles.get("profiles", profiles) if isinstance(profiles.get("profiles"), dict) else profiles + for profile_name, profile_data in profile_entries.items(): + if not isinstance(profile_data, dict): + continue + # Canonical field is "key", "apiKey" is accepted as alias + api_key = profile_data.get("key", "") or profile_data.get("apiKey", "") + if not isinstance(api_key, str) or not api_key.strip(): + continue + name_lower = profile_name.lower() + if "openrouter" in name_lower and "OPENROUTER_API_KEY" not in secret_additions: + secret_additions["OPENROUTER_API_KEY"] = api_key.strip() + elif "openai" in name_lower and "OPENAI_API_KEY" not in secret_additions: + secret_additions["OPENAI_API_KEY"] = api_key.strip() + elif "anthropic" in name_lower and "ANTHROPIC_API_KEY" not in secret_additions: + secret_additions["ANTHROPIC_API_KEY"] = api_key.strip() + except (json.JSONDecodeError, OSError): + pass + if secret_additions: self.merge_env_values(secret_additions, "provider-keys", self.source_root / "openclaw.json") else: @@ -1244,22 +1323,44 @@ class Migrator: if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge"): tts_data["provider"] = provider - elevenlabs = tts.get("elevenlabs", {}) + # TTS provider settings live under messages.tts.providers.{provider} + # in OpenClaw (not messages.tts.elevenlabs directly) + providers = tts.get("providers") or {} + + # Also check the top-level "talk" config which has provider settings too + talk_cfg = (config or self.load_openclaw_config()).get("talk") or {} + talk_providers = talk_cfg.get("providers") or {} + + # Merge: messages.tts.providers takes priority, then talk.providers, + # then legacy flat keys (messages.tts.elevenlabs, etc.) + elevenlabs = ( + (providers.get("elevenlabs") or {}) + if isinstance(providers.get("elevenlabs"), dict) else + (talk_providers.get("elevenlabs") or {}) + if isinstance(talk_providers.get("elevenlabs"), dict) else + (tts.get("elevenlabs") or {}) + ) if isinstance(elevenlabs, dict): el_settings: Dict[str, str] = {} - voice_id = elevenlabs.get("voiceId") + voice_id = elevenlabs.get("voiceId") or talk_cfg.get("voiceId") if isinstance(voice_id, str) and voice_id.strip(): el_settings["voice_id"] = voice_id.strip() - model_id = elevenlabs.get("modelId") + model_id = elevenlabs.get("modelId") or talk_cfg.get("modelId") if isinstance(model_id, str) and model_id.strip(): el_settings["model_id"] = model_id.strip() if el_settings: tts_data["elevenlabs"] = el_settings - openai_tts = tts.get("openai", {}) + openai_tts = ( + (providers.get("openai") or {}) + if isinstance(providers.get("openai"), dict) else + (talk_providers.get("openai") or {}) + if isinstance(talk_providers.get("openai"), dict) else + (tts.get("openai") or {}) + ) if isinstance(openai_tts, dict): oai_settings: Dict[str, str] = {} - oai_model = openai_tts.get("model") + oai_model = openai_tts.get("model") or openai_tts.get("modelId") if isinstance(oai_model, str) and oai_model.strip(): oai_settings["model"] = oai_model.strip() oai_voice = openai_tts.get("voice") @@ -1268,7 +1369,11 @@ class Migrator: if oai_settings: tts_data["openai"] = oai_settings - edge_tts = tts.get("edge", {}) + edge_tts = ( + (providers.get("edge") or {}) + if isinstance(providers.get("edge"), dict) else + (tts.get("edge") or {}) + ) if isinstance(edge_tts, dict): edge_voice = edge_tts.get("voice") if isinstance(edge_voice, str) and edge_voice.strip(): @@ -1298,15 +1403,29 @@ class Migrator: self.record("tts-config", source_path, destination, "migrated", "Would set TTS config", settings=list(tts_data.keys())) def migrate_shared_skills(self) -> None: - source_root = self.source_root / "skills" + # Check all OpenClaw skill sources: managed, personal, project-level + skill_sources = [ + (self.source_root / "skills", "shared-skills", "managed skills"), + (Path.home() / ".agents" / "skills", "personal-skills", "personal cross-project skills"), + (self.source_root / "workspace" / ".agents" / "skills", "project-skills", "project-level shared skills"), + (self.source_root / "workspace.default" / ".agents" / "skills", "project-skills", "project-level shared skills"), + ] + found_any = False + for source_root, kind_label, desc in skill_sources: + if source_root.exists(): + found_any = True + self._import_skill_directory(source_root, kind_label, desc) + if not found_any: + destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME + self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directories found") + + def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str) -> None: + """Import skills from a single source directory into openclaw-imports.""" destination_root = self.target_root / "skills" / SKILL_CATEGORY_DIRNAME - if not source_root.exists(): - self.record("shared-skills", None, destination_root, "skipped", "No shared OpenClaw skills directory found") - return skill_dirs = [p for p in sorted(source_root.iterdir()) if p.is_dir() and (p / "SKILL.md").exists()] if not skill_dirs: - self.record("shared-skills", source_root, destination_root, "skipped", "No shared skills with SKILL.md found") + self.record(kind_label, source_root, destination_root, "skipped", f"No skills with SKILL.md found in {desc}") return for skill_dir in skill_dirs: @@ -1314,7 +1433,7 @@ class Migrator: final_destination = destination if destination.exists(): if self.skill_conflict_mode == "skip": - self.record("shared-skill", skill_dir, destination, "conflict", "Destination skill already exists") + self.record(kind_label, skill_dir, destination, "conflict", "Destination skill already exists") continue if self.skill_conflict_mode == "rename": final_destination = self.resolve_skill_destination(destination) @@ -1329,19 +1448,19 @@ class Migrator: details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""} if final_destination != destination: details["renamed_from"] = str(destination) - self.record("shared-skill", skill_dir, final_destination, "migrated", **details) + self.record(kind_label, skill_dir, final_destination, "migrated", **details) else: if final_destination != destination: self.record( - "shared-skill", + kind_label, skill_dir, final_destination, "migrated", - "Would copy shared skill directory under a renamed folder", + f"Would copy {desc} directory under a renamed folder", renamed_from=str(destination), ) else: - self.record("shared-skill", skill_dir, final_destination, "migrated", "Would copy shared skill directory") + self.record(kind_label, skill_dir, final_destination, "migrated", f"Would copy {desc} directory") desc_path = destination_root / "DESCRIPTION.md" if self.execute: @@ -1518,6 +1637,7 @@ class Migrator: self.source_candidate("workspace/IDENTITY.md", "workspace.default/IDENTITY.md"), self.source_candidate("workspace/TOOLS.md", "workspace.default/TOOLS.md"), self.source_candidate("workspace/HEARTBEAT.md", "workspace.default/HEARTBEAT.md"), + self.source_candidate("workspace/BOOTSTRAP.md", "workspace.default/BOOTSTRAP.md"), ] for candidate in candidates: if candidate: @@ -1789,8 +1909,9 @@ class Migrator: human_delay = defaults.get("humanDelay") or {} if human_delay: hd = hermes_cfg.get("human_delay") or {} - if human_delay.get("enabled"): - hd["mode"] = "natural" + hd_mode = human_delay.get("mode") or ("natural" if human_delay.get("enabled") else None) + if hd_mode and hd_mode != "off": + hd["mode"] = hd_mode if human_delay.get("minMs"): hd["min_ms"] = human_delay["minMs"] if human_delay.get("maxMs"): @@ -1804,11 +1925,11 @@ class Migrator: changes = True # Map terminal/exec settings - exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {} + exec_cfg = (config.get("tools") or {}).get("exec") or {} if exec_cfg: terminal_cfg = hermes_cfg.get("terminal") or {} - if exec_cfg.get("timeout"): - terminal_cfg["timeout"] = exec_cfg["timeout"] + if exec_cfg.get("timeoutSec") or exec_cfg.get("timeout"): + terminal_cfg["timeout"] = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout") changes = True hermes_cfg["terminal"] = terminal_cfg @@ -1883,24 +2004,34 @@ class Migrator: sr = hermes_cfg.get("session_reset") or {} changes = False - reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {} - if reset_triggers: - daily = reset_triggers.get("daily") or {} - idle = reset_triggers.get("idle") or {} + # OpenClaw uses session.reset (structured) and session.resetTriggers (string array) + reset = session.get("reset") or {} + reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or [] - if daily.get("enabled") and idle.get("enabled"): - sr["mode"] = "both" - elif daily.get("enabled"): + if reset: + # Structured reset config: has mode, atHour, idleMinutes + mode = reset.get("mode", "") + if mode == "daily": sr["mode"] = "daily" - elif idle.get("enabled"): + elif mode == "idle": sr["mode"] = "idle" else: - sr["mode"] = "none" - - if daily.get("hour") is not None: - sr["at_hour"] = daily["hour"] - if idle.get("minutes") or idle.get("timeoutMinutes"): - sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes") + sr["mode"] = mode or "none" + if reset.get("atHour") is not None: + sr["at_hour"] = reset["atHour"] + if reset.get("idleMinutes"): + sr["idle_minutes"] = reset["idleMinutes"] + changes = True + elif isinstance(reset_triggers, list) and reset_triggers: + # Simple string triggers: ["daily", "idle"] + has_daily = "daily" in reset_triggers + has_idle = "idle" in reset_triggers + if has_daily and has_idle: + sr["mode"] = "both" + elif has_daily: + sr["mode"] = "daily" + elif has_idle: + sr["mode"] = "idle" changes = True if changes: @@ -2092,11 +2223,12 @@ class Migrator: browser_hermes = hermes_cfg.get("browser") or {} changed = False - if browser.get("inactivityTimeoutMs"): - browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000 + # Map fields that have Hermes equivalents + if browser.get("cdpUrl"): + browser_hermes["cdp_url"] = browser["cdpUrl"] changed = True - if browser.get("commandTimeoutMs"): - browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000 + if browser.get("headless") is not None: + browser_hermes["headless"] = browser["headless"] changed = True if changed: @@ -2107,9 +2239,9 @@ class Migrator: self.record("browser-config", "openclaw.json browser.*", "config.yaml browser", "migrated") - # Archive advanced browser settings + # Archive remaining browser settings advanced = {k: v for k, v in browser.items() - if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v} + if k not in ("cdpUrl", "headless") and v} if advanced and self.archive_dir: if self.execute: self.archive_dir.mkdir(parents=True, exist_ok=True) @@ -2130,18 +2262,22 @@ class Migrator: hermes_cfg = load_yaml_file(hermes_cfg_path) changed = False - # Map exec timeout -> terminal timeout + # Map exec timeout -> terminal timeout (field is timeoutSec in OpenClaw) exec_cfg = tools.get("exec") or {} - if exec_cfg.get("timeout"): + timeout_val = exec_cfg.get("timeoutSec") or exec_cfg.get("timeout") + if timeout_val: terminal_cfg = hermes_cfg.get("terminal") or {} - terminal_cfg["timeout"] = exec_cfg["timeout"] + terminal_cfg["timeout"] = timeout_val hermes_cfg["terminal"] = terminal_cfg changed = True - # Map web search API key - web_cfg = tools.get("webSearch") or tools.get("web") or {} - if web_cfg.get("braveApiKey") and self.migrate_secrets: - self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey") + # Map web search API key (path: tools.web.search.brave.apiKey in OpenClaw) + web_cfg = tools.get("web") or tools.get("webSearch") or {} + search_cfg = web_cfg.get("search") or web_cfg if not web_cfg.get("search") else web_cfg["search"] + brave_cfg = search_cfg.get("brave") or {} + brave_key = brave_cfg.get("apiKey") or search_cfg.get("braveApiKey") or web_cfg.get("braveApiKey") + if brave_key and isinstance(brave_key, str) and self.migrate_secrets: + self._set_env_var("BRAVE_API_KEY", brave_key, "tools.web.search.brave.apiKey") if changed and self.execute: self.maybe_backup(hermes_cfg_path) @@ -2169,8 +2305,9 @@ class Migrator: hermes_cfg_path = self.target_root / "config.yaml" hermes_cfg = load_yaml_file(hermes_cfg_path) - # Map approval mode - mode = approvals.get("mode") or approvals.get("defaultMode") + # Map approval mode (nested under approvals.exec.mode in OpenClaw) + exec_approvals = approvals.get("exec") or {} + mode = (exec_approvals.get("mode") if isinstance(exec_approvals, dict) else None) or approvals.get("mode") or approvals.get("defaultMode") if mode: mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"} hermes_mode = mode_map.get(mode, "manual") diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index d27a34f2cc..eeec7459de 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -466,7 +466,7 @@ hermes insights [--days N] [--source platform] hermes claw migrate [options] ``` -Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. +Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`). | Option | Description | |--------|-------------| @@ -497,6 +497,8 @@ The migration covers your entire OpenClaw footprint. Items are either **directly | **MCP servers** | MCP server definitions | `config.yaml` mcp\_servers | | **User skills** | Workspace skills | `~/.hermes/skills/openclaw-imports/` | | **Shared skills** | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | +| **Personal skills** | `~/.agents/skills/` (cross-project) | `~/.hermes/skills/openclaw-imports/` | +| **Project skills** | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | | **Command allowlist** | Exec approval patterns | `config.yaml` command\_allowlist | | **Messaging settings** | Allowlists, working directory | `config.yaml` messaging section | | **Session policies** | Daily/idle reset policies | `config.yaml` session\_reset | @@ -513,7 +515,7 @@ The migration covers your entire OpenClaw footprint. Items are either **directly | **WhatsApp settings** | Allowlist | `~/.hermes/.env` | | **Signal settings** | Account, HTTP URL, allowlist | `~/.hermes/.env` | | **Channel config** | Matrix, Mattermost, IRC, group settings | `config.yaml` + archive | -| **Provider API keys** | OPENROUTER\_API\_KEY, OPENAI\_API\_KEY, ANTHROPIC\_API\_KEY, etc. | `~/.hermes/.env` (requires `--migrate-secrets`) | +| **Provider API keys** | Config, `~/.openclaw/.env`, and `auth-profiles.json` | `~/.hermes/.env` (requires `--migrate-secrets`) | #### Archived for manual review @@ -531,7 +533,7 @@ These OpenClaw features don't have direct Hermes equivalents. They're saved to a ### Security -API keys are **not migrated by default**. The `--preset full` preset enables secret migration, but only for an allowlist of known keys: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. +API keys are **not migrated by default**. The `--preset full` preset enables secret migration. Keys are collected from three sources (config values take priority, then `.env`, then `auth-profiles.json`) for these targets: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. ### Examples From a347921314a0ad5faaeb0c40caed692bb98b737d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:58:12 -0700 Subject: [PATCH 11/52] docs: comprehensive OpenClaw migration guide (#3900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New standalone guide at guides/migrate-from-openclaw.md with: - Complete config key mapping tables for every category - Agent behavior mappings (thinkingDefault → reasoning_effort, etc.) - Session reset policy mapping (session.reset vs resetTriggers) - TTS dual-source explanation (messages.tts.providers + talk config) - MCP server field-by-field mapping - Messaging platform table with exact config paths and env vars - API key resolution: 3 sources, priority order, supported targets - SecretRef handling: plain strings, env templates, SecretRef objects - Post-migration checklist (6 steps) - Troubleshooting section - Complete archived items table with recreation guidance CLI commands reference condensed to summary + link to full guide. Added to sidebar under Guides & Tutorials. --- website/docs/guides/migrate-from-openclaw.md | 242 +++++++++++++++++++ website/docs/reference/cli-commands.md | 57 +---- website/sidebars.ts | 1 + 3 files changed, 248 insertions(+), 52 deletions(-) create mode 100644 website/docs/guides/migrate-from-openclaw.md diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md new file mode 100644 index 0000000000..6c8304a6e4 --- /dev/null +++ b/website/docs/guides/migrate-from-openclaw.md @@ -0,0 +1,242 @@ +--- +sidebar_position: 7 +title: "Migrate from OpenClaw" +description: "Complete guide to migrating your OpenClaw / Clawdbot setup to Hermes Agent — what gets migrated, how config maps, and what to check after." +--- + +# Migrate from OpenClaw + +`hermes claw migrate` imports your OpenClaw (or legacy Clawdbot/Moldbot) setup into Hermes. This guide covers exactly what gets migrated, the config key mappings, and what to verify after migration. + +## Quick start + +```bash +# Preview what would happen (no files changed) +hermes claw migrate --dry-run + +# Run the migration (secrets excluded by default) +hermes claw migrate + +# Full migration including API keys +hermes claw migrate --preset full +``` + +The migration reads from `~/.openclaw/` by default. If you still have a legacy `~/.clawdbot/` or `~/.moldbot/` directory, it's detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). + +## Options + +| Option | Description | +|--------|-------------| +| `--dry-run` | Preview what would be migrated without writing anything. | +| `--preset ` | `full` (default, includes secrets) or `user-data` (excludes API keys). | +| `--overwrite` | Overwrite existing Hermes files on conflicts (default: skip). | +| `--migrate-secrets` | Include API keys (on by default with `--preset full`). | +| `--source ` | Custom OpenClaw directory. | +| `--workspace-target ` | Where to place `AGENTS.md`. | +| `--skill-conflict ` | `skip` (default), `overwrite`, or `rename`. | +| `--yes` | Skip confirmation prompt. | + +## What gets migrated + +### Persona, memory, and instructions + +| What | OpenClaw source | Hermes destination | Notes | +|------|----------------|-------------------|-------| +| Persona | `workspace/SOUL.md` | `~/.hermes/SOUL.md` | Direct copy | +| Workspace instructions | `workspace/AGENTS.md` | `AGENTS.md` in `--workspace-target` | Requires `--workspace-target` flag | +| Long-term memory | `workspace/MEMORY.md` | `~/.hermes/memories/MEMORY.md` | Parsed into entries, merged with existing, deduped. Uses `§` delimiter. | +| User profile | `workspace/USER.md` | `~/.hermes/memories/USER.md` | Same entry-merge logic as memory. | +| Daily memory files | `workspace/memory/*.md` | `~/.hermes/memories/MEMORY.md` | All daily files merged into main memory. | + +All workspace files also check `workspace.default/` as a fallback path. + +### Skills (4 sources) + +| Source | OpenClaw location | Hermes destination | +|--------|------------------|-------------------| +| Workspace skills | `workspace/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Managed/shared skills | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Personal cross-project | `~/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Project-level shared | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | + +Skill conflicts are handled by `--skill-conflict`: `skip` leaves the existing Hermes skill, `overwrite` replaces it, `rename` creates a `-imported` copy. + +### Model and provider configuration + +| What | OpenClaw config path | Hermes destination | Notes | +|------|---------------------|-------------------|-------| +| Default model | `agents.defaults.model` | `config.yaml` → `model` | Can be a string or `{primary, fallbacks}` object | +| Custom providers | `models.providers.*` | `config.yaml` → `custom_providers` | Maps `baseUrl`, `apiType` ("openai"→"chat_completions", "anthropic"→"anthropic_messages") | +| Provider API keys | `models.providers.*.apiKey` | `~/.hermes/.env` | Requires `--migrate-secrets`. See [API key resolution](#api-key-resolution) below. | + +### Agent behavior + +| What | OpenClaw config path | Hermes config path | Mapping | +|------|---------------------|-------------------|---------| +| Max turns | `agents.defaults.timeoutSeconds` | `agent.max_turns` | `timeoutSeconds / 10`, capped at 200 | +| Verbose mode | `agents.defaults.verboseDefault` | `agent.verbose` | "off" / "on" / "full" | +| Reasoning effort | `agents.defaults.thinkingDefault` | `agent.reasoning_effort` | "always"/"high" → "high", "auto"/"medium" → "medium", "off"/"low"/"none"/"minimal" → "low" | +| Compression | `agents.defaults.compaction.mode` | `compression.enabled` | "off" → false, anything else → true | +| Compression model | `agents.defaults.compaction.model` | `compression.summary_model` | Direct string copy | +| Human delay | `agents.defaults.humanDelay.mode` | `human_delay.mode` | "natural" / "custom" / "off" | +| Human delay timing | `agents.defaults.humanDelay.minMs` / `.maxMs` | `human_delay.min_ms` / `.max_ms` | Direct copy | +| Timezone | `agents.defaults.userTimezone` | `timezone` | Direct string copy | +| Exec timeout | `tools.exec.timeoutSec` | `terminal.timeout` | Direct copy (field is `timeoutSec`, not `timeout`) | +| Docker sandbox | `agents.defaults.sandbox.backend` | `terminal.backend` | "docker" → "docker" | +| Docker image | `agents.defaults.sandbox.docker.image` | `terminal.docker_image` | Direct copy | + +### Session reset policies + +| OpenClaw config path | Hermes config path | Notes | +|---------------------|-------------------|-------| +| `session.reset.mode` | `session_reset.mode` | "daily", "idle", or both | +| `session.reset.atHour` | `session_reset.at_hour` | Hour (0–23) for daily reset | +| `session.reset.idleMinutes` | `session_reset.idle_minutes` | Minutes of inactivity | + +Note: OpenClaw also has `session.resetTriggers` (a simple string array like `["daily", "idle"]`). If the structured `session.reset` isn't present, the migration falls back to inferring from `resetTriggers`. + +### MCP servers + +| OpenClaw field | Hermes field | Notes | +|----------------|-------------|-------| +| `mcp.servers.*.command` | `mcp_servers.*.command` | Stdio transport | +| `mcp.servers.*.args` | `mcp_servers.*.args` | | +| `mcp.servers.*.env` | `mcp_servers.*.env` | | +| `mcp.servers.*.cwd` | `mcp_servers.*.cwd` | | +| `mcp.servers.*.url` | `mcp_servers.*.url` | HTTP/SSE transport | +| `mcp.servers.*.tools.include` | `mcp_servers.*.tools.include` | Tool filtering | +| `mcp.servers.*.tools.exclude` | `mcp_servers.*.tools.exclude` | | + +### TTS (text-to-speech) + +TTS settings are read from **two** OpenClaw config locations with this priority: + +1. `messages.tts.providers.{provider}.*` (canonical location) +2. Top-level `talk.providers.{provider}.*` (fallback) +3. Legacy flat keys `messages.tts.{provider}.*` (oldest format) + +| What | Hermes destination | +|------|-------------------| +| Provider name | `config.yaml` → `tts.provider` | +| ElevenLabs voice ID | `config.yaml` → `tts.elevenlabs.voice_id` | +| ElevenLabs model ID | `config.yaml` → `tts.elevenlabs.model_id` | +| OpenAI model | `config.yaml` → `tts.openai.model` | +| OpenAI voice | `config.yaml` → `tts.openai.voice` | +| Edge TTS voice | `config.yaml` → `tts.edge.voice` | +| TTS assets | `~/.hermes/tts/` (file copy) | + +### Messaging platforms + +| Platform | OpenClaw config path | Hermes `.env` variable | Notes | +|----------|---------------------|----------------------|-------| +| Telegram | `channels.telegram.botToken` | `TELEGRAM_BOT_TOKEN` | Token can be string or [SecretRef](#secretref-handling) | +| Telegram | `credentials/telegram-default-allowFrom.json` | `TELEGRAM_ALLOWED_USERS` | Comma-joined from `allowFrom[]` array | +| Discord | `channels.discord.token` | `DISCORD_BOT_TOKEN` | | +| Discord | `channels.discord.allowFrom` | `DISCORD_ALLOWED_USERS` | | +| Slack | `channels.slack.botToken` | `SLACK_BOT_TOKEN` | | +| Slack | `channels.slack.appToken` | `SLACK_APP_TOKEN` | | +| Slack | `channels.slack.allowFrom` | `SLACK_ALLOWED_USERS` | | +| WhatsApp | `channels.whatsapp.allowFrom` | `WHATSAPP_ALLOWED_USERS` | Auth via Baileys QR pairing (not a token) | +| Signal | `channels.signal.account` | `SIGNAL_ACCOUNT` | | +| Signal | `channels.signal.httpUrl` | `SIGNAL_HTTP_URL` | | +| Signal | `channels.signal.allowFrom` | `SIGNAL_ALLOWED_USERS` | | +| Matrix | `channels.matrix.botToken` | `MATRIX_ACCESS_TOKEN` | Via deep-channels migration | +| Mattermost | `channels.mattermost.botToken` | `MATTERMOST_BOT_TOKEN` | Via deep-channels migration | + +### Other config + +| What | OpenClaw path | Hermes path | Notes | +|------|-------------|-------------|-------| +| Approval mode | `approvals.exec.mode` | `config.yaml` → `approvals.mode` | "auto"→"off", "always"→"manual", "smart"→"smart" | +| Command allowlist | `exec-approvals.json` | `config.yaml` → `command_allowlist` | Patterns merged and deduped | +| Browser CDP URL | `browser.cdpUrl` | `config.yaml` → `browser.cdp_url` | | +| Browser headless | `browser.headless` | `config.yaml` → `browser.headless` | | +| Brave search key | `tools.web.search.brave.apiKey` | `.env` → `BRAVE_API_KEY` | Requires `--migrate-secrets` | +| Gateway auth token | `gateway.auth.token` | `.env` → `HERMES_GATEWAY_TOKEN` | Requires `--migrate-secrets` | +| Working directory | `agents.defaults.workspace` | `.env` → `MESSAGING_CWD` | | + +### Archived (no direct Hermes equivalent) + +These are saved to `~/.hermes/migration/openclaw//archive/` for manual review: + +| What | Archive file | How to recreate in Hermes | +|------|-------------|--------------------------| +| `IDENTITY.md` | `archive/workspace/IDENTITY.md` | Merge into `SOUL.md` | +| `TOOLS.md` | `archive/workspace/TOOLS.md` | Hermes has built-in tool instructions | +| `HEARTBEAT.md` | `archive/workspace/HEARTBEAT.md` | Use cron jobs for periodic tasks | +| `BOOTSTRAP.md` | `archive/workspace/BOOTSTRAP.md` | Use context files or skills | +| Cron jobs | `archive/cron-config.json` | Recreate with `hermes cron create` | +| Plugins | `archive/plugins-config.json` | See [plugins guide](../user-guide/features/hooks.md) | +| Hooks/webhooks | `archive/hooks-config.json` | Use `hermes webhook` or gateway hooks | +| Memory backend | `archive/memory-backend-config.json` | Configure via `hermes honcho` | +| Skills registry | `archive/skills-registry-config.json` | Use `hermes skills config` | +| UI/identity | `archive/ui-identity-config.json` | Use `/skin` command | +| Logging | `archive/logging-diagnostics-config.json` | Set in `config.yaml` logging section | +| Multi-agent list | `archive/agents-list.json` | Use Hermes profiles | +| Channel bindings | `archive/bindings.json` | Manual setup per platform | +| Complex channels | `archive/channels-deep-config.json` | Manual platform config | + +## API key resolution + +When `--migrate-secrets` is enabled, API keys are collected from **three sources** in priority order: + +1. **Config values** — `models.providers.*.apiKey` and TTS provider keys in `openclaw.json` +2. **Environment file** — `~/.openclaw/.env` (keys like `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.) +3. **Auth profiles** — `~/.openclaw/agents/main/agent/auth-profiles.json` (per-agent credentials) + +Config values take priority. The `.env` fills any gaps. Auth profiles fill whatever remains. + +### Supported key targets + +`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, `VOICE_TOOLS_OPENAI_KEY` + +Keys not in this allowlist are never copied. + +## SecretRef handling + +OpenClaw config values for tokens and API keys can be in three formats: + +```json +// Plain string +"channels": { "telegram": { "botToken": "123456:ABC-DEF..." } } + +// Environment template +"channels": { "telegram": { "botToken": "${TELEGRAM_BOT_TOKEN}" } } + +// SecretRef object +"channels": { "telegram": { "botToken": { "source": "env", "id": "TELEGRAM_BOT_TOKEN" } } } +``` + +The migration resolves all three formats. For env templates and SecretRef objects with `source: "env"`, it looks up the value in `~/.openclaw/.env`. SecretRef objects with `source: "file"` or `source: "exec"` can't be resolved automatically — those values must be added to Hermes manually after migration. + +## After migration + +1. **Check the migration report** — printed on completion with counts of migrated, skipped, and conflicting items. + +2. **Review archived files** — anything in `~/.hermes/migration/openclaw//archive/` needs manual attention. + +3. **Verify API keys** — run `hermes status` to check provider authentication. + +4. **Test messaging** — if you migrated platform tokens, restart the gateway: `systemctl --user restart hermes-gateway` + +5. **Check session policies** — verify `hermes config get session_reset` matches your expectations. + +6. **Re-pair WhatsApp** — WhatsApp uses QR code pairing (Baileys), not token migration. Run `hermes whatsapp` to pair. + +## Troubleshooting + +### "OpenClaw directory not found" + +The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. + +### "No provider API keys found" + +Keys might be in your `.env` file instead of `openclaw.json`. The migration checks both — make sure `~/.openclaw/.env` exists and has the keys. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically. + +### Skills not appearing after migration + +Imported skills land in `~/.hermes/skills/openclaw-imports/`. Start a new session for them to take effect, or run `/skills` to verify they're loaded. + +### TTS voice not migrated + +OpenClaw stores TTS settings in two places: `messages.tts.providers.*` and the top-level `talk` config. The migration checks both. If your voice ID was set via the OpenClaw UI (stored in a different path), you may need to set it manually: `hermes config set tts.elevenlabs.voice_id YOUR_VOICE_ID`. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index eeec7459de..a9f12d76be 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -481,59 +481,15 @@ Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom pat ### What gets migrated -The migration covers your entire OpenClaw footprint. Items are either **directly imported** into Hermes equivalents or **archived** for manual review when there's no direct mapping. +The migration covers 30+ categories across persona, memory, skills, model providers, messaging platforms, agent behavior, session policies, MCP servers, TTS, and more. Items are either **directly imported** into Hermes equivalents or **archived** for manual review. -#### Directly imported +**Directly imported:** SOUL.md, MEMORY.md, USER.md, AGENTS.md, skills (4 source directories), default model, custom providers, MCP servers, messaging platform tokens and allowlists (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost), agent defaults (reasoning effort, compression, human delay, timezone, sandbox), session reset policies, approval rules, TTS config, browser settings, tool settings, exec timeout, command allowlist, gateway config, and API keys from 3 sources. -| Category | OpenClaw source | Hermes destination | -|----------|----------------|-------------------| -| **Persona** | `SOUL.md` | `~/.hermes/SOUL.md` | -| **Workspace instructions** | `AGENTS.md` | `AGENTS.md` in target workspace | -| **Long-term memory** | `MEMORY.md` | `~/.hermes/MEMORY.md` (merged with existing entries) | -| **User profile** | `USER.md` | `~/.hermes/USER.md` (merged with existing entries) | -| **Daily memory files** | `workspace/memory/` | Merged into `~/.hermes/MEMORY.md` | -| **Default model** | Config model setting | `config.yaml` model section | -| **Custom providers** | Provider definitions (baseUrl, apiType, headers) | `config.yaml` custom\_providers | -| **MCP servers** | MCP server definitions | `config.yaml` mcp\_servers | -| **User skills** | Workspace skills | `~/.hermes/skills/openclaw-imports/` | -| **Shared skills** | `~/.openclaw/skills/` | `~/.hermes/skills/openclaw-imports/` | -| **Personal skills** | `~/.agents/skills/` (cross-project) | `~/.hermes/skills/openclaw-imports/` | -| **Project skills** | `workspace/.agents/skills/` | `~/.hermes/skills/openclaw-imports/` | -| **Command allowlist** | Exec approval patterns | `config.yaml` command\_allowlist | -| **Messaging settings** | Allowlists, working directory | `config.yaml` messaging section | -| **Session policies** | Daily/idle reset policies | `config.yaml` session\_reset | -| **Agent defaults** | Compaction, context, thinking settings | `config.yaml` agent section | -| **Browser settings** | Browser automation config | `config.yaml` browser section | -| **Tool settings** | Exec timeout, sandbox, web search | `config.yaml` tools section | -| **Approval rules** | Approval mode and rules | `config.yaml` approvals section | -| **TTS config** | TTS provider and voice | `config.yaml` tts section | -| **TTS assets** | Workspace TTS files | `~/.hermes/tts/` | -| **Gateway config** | Gateway port and auth | `config.yaml` gateway section | -| **Telegram settings** | Bot token, allowlist | `~/.hermes/.env` | -| **Discord settings** | Bot token, allowlist | `~/.hermes/.env` | -| **Slack settings** | Bot/app tokens, allowlist | `~/.hermes/.env` | -| **WhatsApp settings** | Allowlist | `~/.hermes/.env` | -| **Signal settings** | Account, HTTP URL, allowlist | `~/.hermes/.env` | -| **Channel config** | Matrix, Mattermost, IRC, group settings | `config.yaml` + archive | -| **Provider API keys** | Config, `~/.openclaw/.env`, and `auth-profiles.json` | `~/.hermes/.env` (requires `--migrate-secrets`) | +**Archived for manual review:** Cron jobs, plugins, hooks/webhooks, memory backend (QMD), skills registry config, UI/identity, logging, multi-agent setup, channel bindings, IDENTITY.md, TOOLS.md, HEARTBEAT.md, BOOTSTRAP.md. -#### Archived for manual review +**API key resolution** checks three sources in priority order: config values → `~/.openclaw/.env` → `auth-profiles.json`. All token fields handle plain strings, env templates (`${VAR}`), and SecretRef objects. -These OpenClaw features don't have direct Hermes equivalents. They're saved to an archive directory for you to review and recreate manually. - -| Category | What's archived | How to recreate in Hermes | -|----------|----------------|--------------------------| -| **Cron / scheduled tasks** | Job definitions | Recreate with `hermes cron create` | -| **Plugins** | Plugin configuration, installed extensions | Check the [plugins guide](../user-guide/features/hooks.md) | -| **Hooks and webhooks** | Internal hooks, webhooks, Gmail integration | Use `hermes webhook` or gateway hooks | -| **Memory backend** | QMD, vector search, citation settings | Configure Honcho via `hermes honcho` | -| **Skills registry** | Per-skill enabled/config/env settings | Use `hermes skills config` | -| **UI and identity** | Theme, assistant identity, display prefs | Use `/skin` command or `config.yaml` | -| **Logging** | Diagnostics configuration | Set in `config.yaml` logging section | - -### Security - -API keys are **not migrated by default**. The `--preset full` preset enables secret migration. Keys are collected from three sources (config values take priority, then `.env`, then `auth-profiles.json`) for these targets: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`, `ELEVENLABS_API_KEY`, `TELEGRAM_BOT_TOKEN`, and `VOICE_TOOLS_OPENAI_KEY`. All other secrets are skipped. +For the complete config key mapping, SecretRef handling details, and post-migration checklist, see the **[full migration guide](../guides/migrate-from-openclaw.md)**. ### Examples @@ -549,9 +505,6 @@ hermes claw migrate --preset user-data --overwrite # Migrate from a custom OpenClaw path hermes claw migrate --source /home/user/old-openclaw - -# Migrate and place AGENTS.md in a specific project -hermes claw migrate --workspace-target /home/user/my-project ``` ## Maintenance commands diff --git a/website/sidebars.ts b/website/sidebars.ts index 73c9430318..082b9ce8fd 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -26,6 +26,7 @@ const sidebars: SidebarsConfig = { 'guides/use-mcp-with-hermes', 'guides/use-soul-with-hermes', 'guides/use-voice-mode-with-hermes', + 'guides/migrate-from-openclaw', ], }, { From c288bbfb57f31ef448796c227af2a1e7acf4cd13 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:29:07 +0530 Subject: [PATCH 12/52] fix(cli): prevent status bar wrapping into duplicate rows (#3883) - measure status bar display width using prompt_toolkit cell widths - trim rendered status text when fragments would overflow - add a final single-fragment fallback to prevent wrapping - update width assertions to validate display cells instead of len() --- cli.py | 129 +++++++++++++++++++++++------------ tests/test_cli_status_bar.py | 5 +- 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/cli.py b/cli.py index a601878f24..3371b98213 100644 --- a/cli.py +++ b/cli.py @@ -1355,6 +1355,49 @@ class HermesCLI: return snapshot + @staticmethod + def _status_bar_display_width(text: str) -> int: + """Return terminal cell width for status-bar text. + + len() is not enough for prompt_toolkit layout decisions because some + glyphs can render wider than one Python codepoint. Keeping the status + bar within the real display width prevents it from wrapping onto a + second line and leaving behind duplicate rows. + """ + try: + from prompt_toolkit.utils import get_cwidth + return get_cwidth(text or "") + except Exception: + return len(text or "") + + @classmethod + def _trim_status_bar_text(cls, text: str, max_width: int) -> str: + """Trim status-bar text to a single terminal row.""" + if max_width <= 0: + return "" + try: + from prompt_toolkit.utils import get_cwidth + except Exception: + get_cwidth = None + + if cls._status_bar_display_width(text) <= max_width: + return text + + ellipsis = "..." + ellipsis_width = cls._status_bar_display_width(ellipsis) + if max_width <= ellipsis_width: + return ellipsis[:max_width] + + out = [] + width = 0 + for ch in text: + ch_width = get_cwidth(ch) if get_cwidth else len(ch) + if width + ch_width + ellipsis_width > max_width: + break + out.append(ch) + width += ch_width + return "".join(out).rstrip() + ellipsis + def _build_status_bar_text(self, width: Optional[int] = None) -> str: try: snapshot = self._get_status_bar_snapshot() @@ -1369,11 +1412,12 @@ class HermesCLI: duration_label = snapshot["duration"] if width < 52: - return f"⚕ {snapshot['model_short']} · {duration_label}" + text = f"⚕ {snapshot['model_short']} · {duration_label}" + return self._trim_status_bar_text(text, width) if width < 76: parts = [f"⚕ {snapshot['model_short']}", percent_label] parts.append(duration_label) - return " · ".join(parts) + return self._trim_status_bar_text(" · ".join(parts), width) if snapshot["context_length"]: ctx_total = _format_context_length(snapshot["context_length"]) @@ -1384,7 +1428,7 @@ class HermesCLI: parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label] parts.append(duration_label) - return " │ ".join(parts) + return self._trim_status_bar_text(" │ ".join(parts), width) except Exception: return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}" @@ -1406,53 +1450,54 @@ class HermesCLI: duration_label = snapshot["duration"] if width < 52: - return [ - ("class:status-bar", " ⚕ "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " · "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), - ] - - percent = snapshot["context_percent"] - percent_label = f"{percent}%" if percent is not None else "--" - if width < 76: frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " · "), - (self._status_bar_context_style(percent), percent_label), - ] - frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), ("class:status-bar", " "), - ]) - return frags - - if snapshot["context_length"]: - ctx_total = _format_context_length(snapshot["context_length"]) - ctx_used = format_token_count_compact(snapshot["context_tokens"]) - context_label = f"{ctx_used}/{ctx_total}" + ] else: - context_label = "ctx --" + percent = snapshot["context_percent"] + percent_label = f"{percent}%" if percent is not None else "--" + if width < 76: + frags = [ + ("class:status-bar", " ⚕ "), + ("class:status-bar-strong", snapshot["model_short"]), + ("class:status-bar-dim", " · "), + (self._status_bar_context_style(percent), percent_label), + ("class:status-bar-dim", " · "), + ("class:status-bar-dim", duration_label), + ("class:status-bar", " "), + ] + else: + if snapshot["context_length"]: + ctx_total = _format_context_length(snapshot["context_length"]) + ctx_used = format_token_count_compact(snapshot["context_tokens"]) + context_label = f"{ctx_used}/{ctx_total}" + else: + context_label = "ctx --" - bar_style = self._status_bar_context_style(percent) - frags = [ - ("class:status-bar", " ⚕ "), - ("class:status-bar-strong", snapshot["model_short"]), - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", context_label), - ("class:status-bar-dim", " │ "), - (bar_style, self._build_context_bar(percent)), - ("class:status-bar-dim", " "), - (bar_style, percent_label), - ] - frags.extend([ - ("class:status-bar-dim", " │ "), - ("class:status-bar-dim", duration_label), - ("class:status-bar", " "), - ]) + bar_style = self._status_bar_context_style(percent) + frags = [ + ("class:status-bar", " ⚕ "), + ("class:status-bar-strong", snapshot["model_short"]), + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", context_label), + ("class:status-bar-dim", " │ "), + (bar_style, self._build_context_bar(percent)), + ("class:status-bar-dim", " "), + (bar_style, percent_label), + ("class:status-bar-dim", " │ "), + ("class:status-bar-dim", duration_label), + ("class:status-bar", " "), + ] + + total_width = sum(self._status_bar_display_width(text) for _, text in frags) + if total_width > width: + plain_text = "".join(text for _, text in frags) + trimmed = self._trim_status_bar_text(plain_text, width) + return [("class:status-bar", trimmed)] return frags except Exception: return [("class:status-bar", f" {self._build_status_bar_text()} ")] diff --git a/tests/test_cli_status_bar.py b/tests/test_cli_status_bar.py index 936ec21902..104c58b1f8 100644 --- a/tests/test_cli_status_bar.py +++ b/tests/test_cli_status_bar.py @@ -214,8 +214,9 @@ class TestStatusBarWidthSource: frags = cli_obj._get_status_bar_fragments() total_text = "".join(text for _, text in frags) - assert len(total_text) <= width + 4, ( # +4 for minor padding chars - f"At width={width}, fragment total {len(total_text)} chars overflows " + display_width = cli_obj._status_bar_display_width(total_text) + assert display_width <= width + 4, ( # +4 for minor padding chars + f"At width={width}, fragment total {display_width} cells overflows " f"({total_text!r})" ) From 947faed3bc3961f6d6f6a3af4cf8dc2424a92877 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:02:02 -0700 Subject: [PATCH 13/52] feat(approvals): make dangerous command approval timeout configurable (#3886) * feat(approvals): make dangerous command approval timeout configurable Read `approvals.timeout` from config.yaml (default 60s) instead of hardcoding 60 seconds in both the fallback CLI prompt and the TUI prompt_toolkit callback. Follows the same pattern as `clarify.timeout` which is already configurable via CLI_CONFIG. Closes #3765 * fix: add timeout default to approvals section in DEFAULT_CONFIG --------- Co-authored-by: acsezen --- hermes_cli/callbacks.py | 3 ++- hermes_cli/config.py | 1 + tools/approval.py | 28 ++++++++++++++++++++++------ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index fa51ee1576..87f86b84dc 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -241,7 +241,8 @@ def approval_callback(cli, command: str, description: str) -> str: lock = cli._approval_lock with lock: - timeout = 60 + from cli import CLI_CONFIG + timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60) response_queue = queue.Queue() choices = ["once", "session", "always", "deny"] if len(command) > 70: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4cdbb0af98..69530761dc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -407,6 +407,7 @@ DEFAULT_CONFIG = { # off — skip all approval prompts (equivalent to --yolo) "approvals": { "mode": "manual", + "timeout": 60, }, # Permanently allowed dangerous command patterns (added via "always" approval) diff --git a/tools/approval.py b/tools/approval.py index e7313b002f..95011173fd 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -241,7 +241,7 @@ def save_permanent_allowlist(patterns: set): # ========================================================================= def prompt_dangerous_approval(command: str, description: str, - timeout_seconds: int = 60, + timeout_seconds: int | None = None, allow_permanent: bool = True, approval_callback=None) -> str: """Prompt the user to approve a dangerous command (CLI only). @@ -256,6 +256,9 @@ def prompt_dangerous_approval(command: str, description: str, Returns: 'once', 'session', 'always', or 'deny' """ + if timeout_seconds is None: + timeout_seconds = _get_approval_timeout() + if approval_callback is not None: try: return approval_callback(command, description, @@ -336,15 +339,28 @@ def _normalize_approval_mode(mode) -> str: return "manual" -def _get_approval_mode() -> str: - """Read the approval mode from config. Returns 'manual', 'smart', or 'off'.""" +def _get_approval_config() -> dict: + """Read the approvals config block. Returns a dict with 'mode', 'timeout', etc.""" try: from hermes_cli.config import load_config config = load_config() - mode = config.get("approvals", {}).get("mode", "manual") - return _normalize_approval_mode(mode) + return config.get("approvals", {}) or {} except Exception: - return "manual" + return {} + + +def _get_approval_mode() -> str: + """Read the approval mode from config. Returns 'manual', 'smart', or 'off'.""" + mode = _get_approval_config().get("mode", "manual") + return _normalize_approval_mode(mode) + + +def _get_approval_timeout() -> int: + """Read the approval timeout from config. Defaults to 60 seconds.""" + try: + return int(_get_approval_config().get("timeout", 60)) + except (ValueError, TypeError): + return 60 def _smart_approve(command: str, description: str) -> str: From ee61485cac5590ca3aeb7a42bcdd763ea1a3c588 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:02:51 -0700 Subject: [PATCH 14/52] feat(matrix): support native voice messages via MSC3245 (#3877) * feat(matrix): support native voice messages * fix: skip matrix voice tests when matrix-nio not installed --------- Co-authored-by: Carlos Alberto Pereira Gomes --- gateway/platforms/matrix.py | 58 ++++- tests/gateway/test_matrix_voice.py | 340 +++++++++++++++++++++++++++++ 2 files changed, 392 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_matrix_voice.py diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 3d6a90502c..d8593a3537 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -17,6 +17,8 @@ Environment variables: from __future__ import annotations import asyncio +import io +import json import logging import mimetypes import os @@ -512,8 +514,11 @@ class MatrixAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Upload an audio file as a voice message.""" - return await self._send_local_file(chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata) + """Upload an audio file as a voice message (MSC3245 native voice).""" + return await self._send_local_file( + chat_id, audio_path, "m.audio", caption, reply_to, + metadata=metadata, is_voice=True + ) async def send_video( self, @@ -546,13 +551,16 @@ class MatrixAdapter(BasePlatformAdapter): caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + is_voice: bool = False, ) -> SendResult: """Upload bytes to Matrix and send as a media message.""" import nio # Upload to homeserver. - resp = await self._client.upload( - data, + # nio expects a DataProvider (callable) or file-like object, not raw bytes. + # nio.upload() returns a tuple (UploadResponse|UploadError, Optional[Dict]) + resp, maybe_encryption_info = await self._client.upload( + io.BytesIO(data), content_type=content_type, filename=filename, ) @@ -574,6 +582,10 @@ class MatrixAdapter(BasePlatformAdapter): }, } + # Add MSC3245 voice flag for native voice messages. + if is_voice: + msg_content["org.matrix.msc3245.voice"] = {} + if reply_to: msg_content["m.relates_to"] = { "m.in_reply_to": {"event_id": reply_to} @@ -601,6 +613,7 @@ class MatrixAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, file_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + is_voice: bool = False, ) -> SendResult: """Read a local file and upload it.""" p = Path(file_path) @@ -613,7 +626,7 @@ class MatrixAdapter(BasePlatformAdapter): ct = mimetypes.guess_type(fname)[0] or "application/octet-stream" data = p.read_bytes() - return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata) + return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice) # ------------------------------------------------------------------ # Sync loop @@ -808,11 +821,19 @@ class MatrixAdapter(BasePlatformAdapter): event_mimetype = (content_info.get("info") or {}).get("mimetype", "") media_type = "application/octet-stream" msg_type = MessageType.DOCUMENT + is_voice_message = False + if isinstance(event, nio.RoomMessageImage): msg_type = MessageType.PHOTO media_type = event_mimetype or "image/png" elif isinstance(event, nio.RoomMessageAudio): - msg_type = MessageType.AUDIO + # Check for MSC3245 voice flag: org.matrix.msc3245.voice: {} + source_content = getattr(event, "source", {}).get("content", {}) + if source_content.get("org.matrix.msc3245.voice") is not None: + is_voice_message = True + msg_type = MessageType.VOICE + else: + msg_type = MessageType.AUDIO media_type = event_mimetype or "audio/ogg" elif isinstance(event, nio.RoomMessageVideo): msg_type = MessageType.VIDEO @@ -850,6 +871,31 @@ class MatrixAdapter(BasePlatformAdapter): if relates_to.get("rel_type") == "m.thread": thread_id = relates_to.get("event_id") + # For voice messages, cache audio locally for transcription tools. + # Use the authenticated nio client to download (Matrix requires auth for media). + media_urls = [http_url] if http_url else None + media_types = [media_type] if http_url else None + + if is_voice_message and url and url.startswith("mxc://"): + try: + import nio + from gateway.platforms.base import cache_audio_from_bytes + + resp = await self._client.download(mxc=url) + if isinstance(resp, nio.MemoryDownloadResponse): + # Extract extension from mimetype or default to .ogg + ext = ".ogg" + if media_type and "/" in media_type: + subtype = media_type.split("/")[1] + ext = f".{subtype}" if subtype else ".ogg" + local_path = cache_audio_from_bytes(resp.body, ext) + media_urls = [local_path] + logger.debug("Matrix: cached voice message to %s", local_path) + else: + logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp)) + except Exception as e: + logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e) + source = self.build_source( chat_id=room.room_id, chat_type=chat_type, diff --git a/tests/gateway/test_matrix_voice.py b/tests/gateway/test_matrix_voice.py new file mode 100644 index 0000000000..79f0947f61 --- /dev/null +++ b/tests/gateway/test_matrix_voice.py @@ -0,0 +1,340 @@ +"""Tests for Matrix voice message support (MSC3245).""" +import io + +import pytest +from unittest.mock import AsyncMock, MagicMock + +nio = pytest.importorskip("nio", reason="matrix-nio not installed") + +from gateway.platforms.base import MessageType + + +# --------------------------------------------------------------------------- +# Adapter helpers +# --------------------------------------------------------------------------- + +def _make_adapter(): + """Create a MatrixAdapter with mocked config.""" + from gateway.platforms.matrix import MatrixAdapter + from gateway.config import PlatformConfig + + config = PlatformConfig( + enabled=True, + token="***", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + }, + ) + adapter = MatrixAdapter(config) + return adapter + + +def _make_room(room_id: str = "!test:example.org", member_count: int = 2): + """Create a mock Matrix room.""" + room = MagicMock() + room.room_id = room_id + room.member_count = member_count + return room + + +def _make_audio_event( + event_id: str = "$audio_event", + sender: str = "@alice:example.org", + body: str = "Voice message", + url: str = "mxc://example.org/abc123", + is_voice: bool = False, + mimetype: str = "audio/ogg", + timestamp: float = 9999999999000, # ms +): + """ + Create a mock RoomMessageAudio event that passes isinstance checks. + + Args: + is_voice: If True, adds org.matrix.msc3245.voice field to content + """ + import nio + + # Build the source dict that nio events expose via .source + content = { + "msgtype": "m.audio", + "body": body, + "url": url, + "info": { + "mimetype": mimetype, + }, + } + + if is_voice: + content["org.matrix.msc3245.voice"] = {} + + # Create a real nio RoomMessageAudio-like object + # We use MagicMock but configure __class__ to pass isinstance check + event = MagicMock(spec=nio.RoomMessageAudio) + event.event_id = event_id + event.sender = sender + event.body = body + event.url = url + event.server_timestamp = timestamp + event.source = { + "type": "m.room.message", + "content": content, + } + # For MIME type extraction - needs to be a dict + event.content = content + + return event + + +def _make_download_response(body: bytes = b"fake audio data"): + """Create a mock nio.MemoryDownloadResponse.""" + import nio + resp = MagicMock() + resp.body = body + resp.__class__ = nio.MemoryDownloadResponse + return resp + + +# --------------------------------------------------------------------------- +# Tests: MSC3245 Voice Detection (RED -> GREEN) +# --------------------------------------------------------------------------- + +class TestMatrixVoiceMessageDetection: + """Test that MSC3245 voice messages are detected and tagged correctly.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._user_id = "@bot:example.org" + self.adapter._startup_ts = 0.0 + self.adapter._dm_rooms = {} + self.adapter._message_handler = AsyncMock() + # Mock _mxc_to_http to return a fake HTTP URL + self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}" + # Mock client for authenticated download + self.adapter._client = MagicMock() + self.adapter._client.download = AsyncMock(return_value=_make_download_response()) + + @pytest.mark.asyncio + async def test_voice_message_has_type_voice(self): + """Voice messages (with MSC3245 field) should be MessageType.VOICE.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + # Capture the MessageEvent passed to handle_message + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None, "No event was captured" + assert captured_event.message_type == MessageType.VOICE, \ + f"Expected MessageType.VOICE, got {captured_event.message_type}" + + @pytest.mark.asyncio + async def test_voice_message_has_local_path(self): + """Voice messages should have a local cached path in media_urls.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + assert len(captured_event.media_urls) > 0 + # Should be a local path, not an HTTP URL + assert not captured_event.media_urls[0].startswith("http"), \ + f"media_urls should contain local path, got {captured_event.media_urls[0]}" + self.adapter._client.download.assert_awaited_once_with(mxc=event.url) + assert captured_event.media_types == ["audio/ogg"] + + @pytest.mark.asyncio + async def test_audio_without_msc3245_stays_audio_type(self): + """Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO.""" + room = _make_room() + event = _make_audio_event(is_voice=False) # NOT a voice message + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.message_type == MessageType.AUDIO, \ + f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}" + + @pytest.mark.asyncio + async def test_regular_audio_has_http_url(self): + """Regular audio uploads should keep HTTP URL (not cached locally).""" + room = _make_room() + event = _make_audio_event(is_voice=False) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + # Should be HTTP URL, not local path + assert captured_event.media_urls[0].startswith("http"), \ + f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}" + self.adapter._client.download.assert_not_awaited() + assert captured_event.media_types == ["audio/ogg"] + + +class TestMatrixVoiceCacheFallback: + """Test graceful fallback when voice caching fails.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._user_id = "@bot:example.org" + self.adapter._startup_ts = 0.0 + self.adapter._dm_rooms = {} + self.adapter._message_handler = AsyncMock() + self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}" + self.adapter._client = MagicMock() + + @pytest.mark.asyncio + async def test_voice_cache_failure_falls_back_to_http_url(self): + """If caching fails, voice message should still be delivered with HTTP URL.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + # Make download fail + import nio + error_resp = MagicMock() + error_resp.__class__ = nio.DownloadError + self.adapter._client.download = AsyncMock(return_value=error_resp) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + # Should fall back to HTTP URL + assert captured_event.media_urls[0].startswith("http"), \ + f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}" + + @pytest.mark.asyncio + async def test_voice_cache_exception_falls_back_to_http_url(self): + """Unexpected download exceptions should also fall back to HTTP URL.""" + room = _make_room() + event = _make_audio_event(is_voice=True) + + self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom")) + + captured_event = None + + async def capture(msg_event): + nonlocal captured_event + captured_event = msg_event + + self.adapter.handle_message = capture + + await self.adapter._on_room_message_media(room, event) + + assert captured_event is not None + assert captured_event.media_urls is not None + assert captured_event.media_urls[0].startswith("http"), \ + f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}" + + +# --------------------------------------------------------------------------- +# Tests: send_voice includes MSC3245 field (RED -> GREEN) +# --------------------------------------------------------------------------- + +class TestMatrixSendVoiceMSC3245: + """Test that send_voice includes MSC3245 field for native voice rendering.""" + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._user_id = "@bot:example.org" + # Mock client with successful upload + self.adapter._client = MagicMock() + self.upload_call = None + + async def mock_upload(*args, **kwargs): + self.upload_call = (args, kwargs) + import nio + resp = MagicMock() + resp.content_uri = "mxc://example.org/uploaded" + resp.__class__ = nio.UploadResponse + return resp, None + + self.adapter._client.upload = mock_upload + + @pytest.mark.asyncio + async def test_send_voice_includes_msc3245_field(self): + """send_voice should include org.matrix.msc3245.voice in message content.""" + import tempfile + import os + + # Create a temp audio file + with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f: + f.write(b"fake audio data") + temp_path = f.name + + try: + # Capture the message content sent to room_send + sent_content = None + + async def mock_room_send(room_id, event_type, content): + nonlocal sent_content + sent_content = content + resp = MagicMock() + resp.event_id = "$sent_event" + import nio + resp.__class__ = nio.RoomSendResponse + return resp + + self.adapter._client.room_send = mock_room_send + + await self.adapter.send_voice( + chat_id="!room:example.org", + audio_path=temp_path, + caption="Test voice", + ) + + assert sent_content is not None, "No message was sent" + assert "org.matrix.msc3245.voice" in sent_content, \ + f"MSC3245 voice field missing from content: {sent_content.keys()}" + assert sent_content["msgtype"] == "m.audio" + assert sent_content["info"]["mimetype"] == "audio/ogg" + assert self.upload_call is not None, "Expected upload() to be called" + args, kwargs = self.upload_call + assert isinstance(args[0], io.BytesIO) + assert kwargs["content_type"] == "audio/ogg" + assert kwargs["filename"].endswith(".ogg") + + finally: + os.unlink(temp_path) From 138ea3fbe8b8af57772c294816d30ac79a394953 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:09:30 -0700 Subject: [PATCH 15/52] fix(docs): escape angle-bracket URLs in feishu.md breaking MDX build (#3902) --- website/docs/user-guide/messaging/feishu.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index f515648b86..1b7141e78c 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -32,8 +32,8 @@ Set it to `false` only if you explicitly want one shared conversation per chat. ## Step 1: Create a Feishu / Lark App 1. Open the Feishu or Lark developer console: - - Feishu: - - Lark: + - Feishu: [https://open.feishu.cn/](https://open.feishu.cn/) + - Lark: [https://open.larksuite.com/](https://open.larksuite.com/) 2. Create a new app. 3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**. 4. Enable the **Bot** capability for the app. From a4b064763d2fd4ce26b4d617c488fcdd57a50e76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:11:00 -0700 Subject: [PATCH 16/52] fix(cron): tighten [SILENT] instruction to prevent report-with-silent-prefix (#3901) The model was interpreting [SILENT] as a metadata prefix and writing full reports with [SILENT] slapped at the front. The old instruction said 'optionally followed by a brief internal note' which gave too much room. New instruction explicitly says: [SILENT] means nothing else, do NOT combine it with a report. --- cron/scheduler.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index e4299836f4..a03f00b76d 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -236,11 +236,12 @@ def _build_job_prompt(job: dict) -> str: # Always prepend [SILENT] guidance so the cron agent can suppress # delivery when it has nothing new or noteworthy to report. silent_hint = ( - "[SYSTEM: If you have nothing new or noteworthy to report, respond " - "with exactly \"[SILENT]\" (optionally followed by a brief internal " - "note). This suppresses delivery to the user while still saving " - "output locally. Only use [SILENT] when there are genuinely no " - "changes worth reporting.]\n\n" + "[SYSTEM: If you have a meaningful status report or findings, " + "send them — that is the whole point of this job. Only respond " + "with exactly \"[SILENT]\" (nothing else) when there is genuinely " + "nothing new to report. [SILENT] suppresses delivery to the user. " + "Never combine [SILENT] with content — either report your " + "findings normally, or say [SILENT] and nothing more.]\n\n" ) prompt = silent_hint + prompt if skills is None: From 791f4e94b27ccf6bdbcb02f6358e5d7e029f2209 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:51:48 -0700 Subject: [PATCH 17/52] feat(slack): multi-workspace support via OAuth token file (#3903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from PR #2033 by yoannes. Adds multi-workspace Slack support so a single Hermes instance can serve multiple Slack workspaces after OAuth installs. Changes: - Support comma-separated bot tokens in SLACK_BOT_TOKEN env var - Load additional OAuth-persisted tokens from HERMES_HOME/slack_tokens.json - Route all Slack API calls through workspace-aware _get_client(chat_id) instead of always using the primary app client - Track channel → workspace mapping from incoming events - Per-workspace bot_user_id for correct mention detection - Workspace-aware file downloads (correct auth token per workspace) Backward compatible: single-token setups work identically. Token file format (slack_tokens.json): {"T12345": {"token": "xoxb-...", "team_name": "My Workspace"}} Fixed from original PR: - Uses get_hermes_home() instead of hardcoded ~/.hermes/ path Co-authored-by: yoannes --- gateway/platforms/slack.py | 125 ++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 31 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 2a7e046f85..88540815e5 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -9,6 +9,7 @@ Uses slack-bolt (Python) with Socket Mode for: """ import asyncio +import json import logging import os import re @@ -73,6 +74,10 @@ class SlackAdapter(BasePlatformAdapter): self._bot_user_id: Optional[str] = None self._user_name_cache: Dict[str, str] = {} # user_id → display name self._socket_mode_task: Optional[asyncio.Task] = None + # Multi-workspace support + self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient + self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id + self._channel_team: Dict[str, str] = {} # channel_id → team_id async def connect(self) -> bool: """Connect to Slack via Socket Mode.""" @@ -82,16 +87,34 @@ class SlackAdapter(BasePlatformAdapter): ) return False - bot_token = self.config.token + raw_token = self.config.token app_token = os.getenv("SLACK_APP_TOKEN") - if not bot_token: + if not raw_token: logger.error("[Slack] SLACK_BOT_TOKEN not set") return False if not app_token: logger.error("[Slack] SLACK_APP_TOKEN not set") return False + # Support comma-separated bot tokens for multi-workspace + bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()] + + # Also load tokens from OAuth token file + from hermes_constants import get_hermes_home + tokens_file = get_hermes_home() / "slack_tokens.json" + if tokens_file.exists(): + try: + saved = json.loads(tokens_file.read_text(encoding="utf-8")) + for team_id, entry in saved.items(): + tok = entry.get("token", "") if isinstance(entry, dict) else "" + if tok and tok not in bot_tokens: + bot_tokens.append(tok) + team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id + logger.info("[Slack] Loaded saved token for workspace %s", team_label) + except Exception as e: + logger.warning("[Slack] Failed to read %s: %s", tokens_file, e) + try: # Acquire scoped lock to prevent duplicate app token usage from gateway.status import acquire_scoped_lock @@ -104,12 +127,30 @@ class SlackAdapter(BasePlatformAdapter): self._set_fatal_error('slack_token_lock', message, retryable=False) return False - self._app = AsyncApp(token=bot_token) + # First token is the primary — used for AsyncApp / Socket Mode + primary_token = bot_tokens[0] + self._app = AsyncApp(token=primary_token) - # Get our own bot user ID for mention detection - auth_response = await self._app.client.auth_test() - self._bot_user_id = auth_response.get("user_id") - bot_name = auth_response.get("user", "unknown") + # Register each bot token and map team_id → client + for token in bot_tokens: + client = AsyncWebClient(token=token) + auth_response = await client.auth_test() + team_id = auth_response.get("team_id", "") + bot_user_id = auth_response.get("user_id", "") + bot_name = auth_response.get("user", "unknown") + team_name = auth_response.get("team", "unknown") + + self._team_clients[team_id] = client + self._team_bot_user_ids[team_id] = bot_user_id + + # First token sets the primary bot_user_id (backward compat) + if self._bot_user_id is None: + self._bot_user_id = bot_user_id + + logger.info( + "[Slack] Authenticated as @%s in workspace %s (team: %s)", + bot_name, team_name, team_id, + ) # Register message event handler @self._app.event("message") @@ -134,7 +175,10 @@ class SlackAdapter(BasePlatformAdapter): self._socket_mode_task = asyncio.create_task(self._handler.start_async()) self._running = True - logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name) + logger.info( + "[Slack] Socket Mode connected (%d workspace(s))", + len(self._team_clients), + ) return True except Exception as e: # pragma: no cover - defensive logging @@ -161,6 +205,13 @@ class SlackAdapter(BasePlatformAdapter): logger.info("[Slack] Disconnected") + def _get_client(self, chat_id: str) -> AsyncWebClient: + """Return the workspace-specific WebClient for a channel.""" + team_id = self._channel_team.get(chat_id) + if team_id and team_id in self._team_clients: + return self._team_clients[team_id] + return self._app.client # fallback to primary + async def send( self, chat_id: str, @@ -197,7 +248,7 @@ class SlackAdapter(BasePlatformAdapter): if broadcast and i == 0: kwargs["reply_broadcast"] = True - last_result = await self._app.client.chat_postMessage(**kwargs) + last_result = await self._get_client(chat_id).chat_postMessage(**kwargs) return SendResult( success=True, @@ -219,7 +270,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return SendResult(success=False, error="Not connected") try: - await self._app.client.chat_update( + await self._get_client(chat_id).chat_update( channel=chat_id, ts=message_id, text=content, @@ -253,7 +304,7 @@ class SlackAdapter(BasePlatformAdapter): return # Can only set status in a thread context try: - await self._app.client.assistant_threads_setStatus( + await self._get_client(chat_id).assistant_threads_setStatus( channel_id=chat_id, thread_ts=thread_ts, status="is thinking...", @@ -295,7 +346,7 @@ class SlackAdapter(BasePlatformAdapter): if not os.path.exists(file_path): raise FileNotFoundError(f"File not found: {file_path}") - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, file=file_path, filename=os.path.basename(file_path), @@ -397,7 +448,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return False try: - await self._app.client.reactions_add( + await self._get_client(channel).reactions_add( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -413,7 +464,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return False try: - await self._app.client.reactions_remove( + await self._get_client(channel).reactions_remove( channel=channel, timestamp=timestamp, name=emoji ) return True @@ -423,7 +474,7 @@ class SlackAdapter(BasePlatformAdapter): # ----- User identity resolution ----- - async def _resolve_user_name(self, user_id: str) -> str: + async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str: """Resolve a Slack user ID to a display name, with caching.""" if not user_id: return "" @@ -434,7 +485,8 @@ class SlackAdapter(BasePlatformAdapter): return user_id try: - result = await self._app.client.users_info(user=user_id) + client = self._get_client(chat_id) if chat_id else self._app.client + result = await client.users_info(user=user_id) user = result.get("user", {}) # Prefer display_name → real_name → user_id profile = user.get("profile", {}) @@ -498,7 +550,7 @@ class SlackAdapter(BasePlatformAdapter): response = await client.get(image_url) response.raise_for_status() - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, content=response.content, filename="image.png", @@ -558,7 +610,7 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error=f"Video file not found: {video_path}") try: - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, file=video_path, filename=os.path.basename(video_path), @@ -599,7 +651,7 @@ class SlackAdapter(BasePlatformAdapter): display_name = file_name or os.path.basename(file_path) try: - result = await self._app.client.files_upload_v2( + result = await self._get_client(chat_id).files_upload_v2( channel=chat_id, file=file_path, filename=display_name, @@ -627,7 +679,7 @@ class SlackAdapter(BasePlatformAdapter): return {"name": chat_id, "type": "unknown"} try: - result = await self._app.client.conversations_info(channel=chat_id) + result = await self._get_client(chat_id).conversations_info(channel=chat_id) channel = result.get("channel", {}) is_dm = channel.get("is_im", False) return { @@ -660,6 +712,11 @@ class SlackAdapter(BasePlatformAdapter): user_id = event.get("user", "") channel_id = event.get("channel", "") ts = event.get("ts", "") + team_id = event.get("team", "") + + # Track which workspace owns this channel + if team_id and channel_id: + self._channel_team[channel_id] = team_id # Determine if this is a DM or channel message channel_type = event.get("channel_type", "") @@ -676,11 +733,12 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = event.get("thread_ts") or ts # ts fallback for channels # In channels, only respond if bot is mentioned - if not is_dm and self._bot_user_id: - if f"<@{self._bot_user_id}>" not in text: + bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) + if not is_dm and bot_uid: + if f"<@{bot_uid}>" not in text: return # Strip the bot mention from the text - text = text.replace(f"<@{self._bot_user_id}>", "").strip() + text = text.replace(f"<@{bot_uid}>", "").strip() # Determine message type msg_type = MessageType.TEXT @@ -700,7 +758,7 @@ class SlackAdapter(BasePlatformAdapter): if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): ext = ".jpg" # Slack private URLs require the bot token as auth header - cached = await self._download_slack_file(url, ext) + cached = await self._download_slack_file(url, ext, team_id=team_id) media_urls.append(cached) media_types.append(mimetype) msg_type = MessageType.PHOTO @@ -711,7 +769,7 @@ class SlackAdapter(BasePlatformAdapter): ext = "." + mimetype.split("/")[-1].split(";")[0] if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): ext = ".ogg" - cached = await self._download_slack_file(url, ext, audio=True) + cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) media_urls.append(cached) media_types.append(mimetype) msg_type = MessageType.VOICE @@ -742,7 +800,7 @@ class SlackAdapter(BasePlatformAdapter): continue # Download and cache - raw_bytes = await self._download_slack_file_bytes(url) + raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id) cached_path = cache_document_from_bytes( raw_bytes, original_filename or f"document{ext}" ) @@ -771,7 +829,7 @@ class SlackAdapter(BasePlatformAdapter): logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True) # Resolve user display name (cached after first lookup) - user_name = await self._resolve_user_name(user_id) + user_name = await self._resolve_user_name(user_id, chat_id=channel_id) # Build source source = self.build_source( @@ -808,6 +866,11 @@ class SlackAdapter(BasePlatformAdapter): text = command.get("text", "").strip() user_id = command.get("user_id", "") channel_id = command.get("channel_id", "") + team_id = command.get("team_id", "") + + # Track which workspace owns this channel + if team_id and channel_id: + self._channel_team[channel_id] = team_id # Map subcommands to gateway commands — derived from central registry. # Also keep "compact" as a Slack-specific alias for /compress. @@ -839,12 +902,12 @@ class SlackAdapter(BasePlatformAdapter): await self.handle_message(event) - async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str: + async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: """Download a Slack file using the bot token for auth, with retry.""" import asyncio import httpx - bot_token = self.config.token + bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token last_exc = None async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: @@ -874,12 +937,12 @@ class SlackAdapter(BasePlatformAdapter): raise raise last_exc - async def _download_slack_file_bytes(self, url: str) -> bytes: + async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes: """Download a Slack file and return raw bytes, with retry.""" import asyncio import httpx - bot_token = self.config.token + bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token last_exc = None async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: From 5148682b432e8df38273af0381d14ee812dfee46 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:45:41 -0700 Subject: [PATCH 18/52] feat: mount skills directory into all remote backends with live sync (#3890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills with scripts/, templates/, and references/ subdirectories need those files available inside sandboxed execution environments. Previously the skills directory was missing entirely from remote backends. Live sync — files stay current as credentials refresh and skills update: - Docker/Singularity: bind mounts are inherently live (host changes visible immediately) - Modal: _sync_files() runs before each command with mtime+size caching, pushing only changed credential and skill files (~13μs no-op overhead) - SSH: rsync --safe-links before each command (naturally incremental) - Daytona: _upload_if_changed() with mtime+size caching before each command Security — symlink filtering: - Docker/Singularity: sanitized temp copy when symlinks detected - Modal/Daytona: iter_skills_files() skips symlinks - SSH: rsync --safe-links skips symlinks pointing outside source tree - Temp dir cleanup via atexit + reuse across calls Non-root user support: - SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/ - Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/ - Docker/Modal/Singularity: run as root, /root/.hermes/ is correct Also: - credential_files.py: fix name/path key fallback in required_credential_files - Singularity, SSH, Daytona: gained credential file support - 14 tests covering symlink filtering, name/path fallback, iter_skills_files --- tests/tools/test_credential_files.py | 275 ++++++++++++++---------- tests/tools/test_daytona_environment.py | 4 + tools/credential_files.py | 103 ++++++++- tools/environments/daytona.py | 65 +++++- tools/environments/docker.py | 16 +- tools/environments/modal.py | 113 +++++----- tools/environments/singularity.py | 22 ++ tools/environments/ssh.py | 75 +++++++ 8 files changed, 494 insertions(+), 179 deletions(-) diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 293e2c6da7..c46f73fae3 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -1,13 +1,17 @@ -"""Tests for credential file passthrough registry (tools/credential_files.py).""" +"""Tests for credential file passthrough and skills directory mounting.""" +import json import os from pathlib import Path +from unittest.mock import patch import pytest from tools.credential_files import ( clear_credential_files, get_credential_file_mounts, + get_skills_directory_mount, + iter_skills_files, register_credential_file, register_credential_files, reset_config_cache, @@ -15,8 +19,8 @@ from tools.credential_files import ( @pytest.fixture(autouse=True) -def _clean_registry(): - """Reset registry between tests.""" +def _clean_state(): + """Reset module state between tests.""" clear_credential_files() reset_config_cache() yield @@ -24,135 +28,172 @@ def _clean_registry(): reset_config_cache() -class TestRegisterCredentialFile: - def test_registers_existing_file(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "token.json").write_text('{"token": "abc"}') +class TestRegisterCredentialFiles: + def test_dict_with_path_key(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "token.json").write_text("{}") - result = register_credential_file("token.json") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([{"path": "token.json"}]) - assert result is True + assert missing == [] mounts = get_credential_file_mounts() assert len(mounts) == 1 - assert mounts[0]["host_path"] == str(tmp_path / "token.json") + assert mounts[0]["host_path"] == str(hermes_home / "token.json") assert mounts[0]["container_path"] == "/root/.hermes/token.json" - def test_skips_missing_file(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + def test_dict_with_name_key_fallback(self, tmp_path): + """Skills use 'name' instead of 'path' — both should work.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "google_token.json").write_text("{}") - result = register_credential_file("nonexistent.json") - - assert result is False - assert get_credential_file_mounts() == [] - - def test_custom_container_base(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "cred.json").write_text("{}") - - register_credential_file("cred.json", container_base="/home/user/.hermes") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([ + {"name": "google_token.json", "description": "OAuth token"}, + ]) + assert missing == [] mounts = get_credential_file_mounts() - assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json" + assert len(mounts) == 1 + assert "google_token.json" in mounts[0]["container_path"] - def test_deduplicates_by_container_path(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "token.json").write_text("{}") + def test_string_entry(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "secret.key").write_text("key") - register_credential_file("token.json") - register_credential_file("token.json") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files(["secret.key"]) + assert missing == [] mounts = get_credential_file_mounts() assert len(mounts) == 1 + def test_missing_file_reported(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() -class TestRegisterCredentialFiles: - def test_string_entries(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "a.json").write_text("{}") - (tmp_path / "b.json").write_text("{}") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([ + {"name": "does_not_exist.json"}, + ]) - missing = register_credential_files(["a.json", "b.json"]) - - assert missing == [] - assert len(get_credential_file_mounts()) == 2 - - def test_dict_entries(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "token.json").write_text("{}") - - missing = register_credential_files([ - {"path": "token.json", "description": "OAuth token"}, - ]) - - assert missing == [] - assert len(get_credential_file_mounts()) == 1 - - def test_returns_missing_files(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "exists.json").write_text("{}") - - missing = register_credential_files([ - "exists.json", - "missing.json", - {"path": "also_missing.json"}, - ]) - - assert missing == ["missing.json", "also_missing.json"] - assert len(get_credential_file_mounts()) == 1 - - def test_empty_list(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - assert register_credential_files([]) == [] - - -class TestConfigCredentialFiles: - def test_loads_from_config(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "oauth.json").write_text("{}") - (tmp_path / "config.yaml").write_text( - "terminal:\n credential_files:\n - oauth.json\n" - ) - - mounts = get_credential_file_mounts() - - assert len(mounts) == 1 - assert mounts[0]["host_path"] == str(tmp_path / "oauth.json") - - def test_config_skips_missing_files(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "config.yaml").write_text( - "terminal:\n credential_files:\n - nonexistent.json\n" - ) - - mounts = get_credential_file_mounts() - assert mounts == [] - - def test_combines_skill_and_config(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - (tmp_path / "skill_token.json").write_text("{}") - (tmp_path / "config_token.json").write_text("{}") - (tmp_path / "config.yaml").write_text( - "terminal:\n credential_files:\n - config_token.json\n" - ) - - register_credential_file("skill_token.json") - mounts = get_credential_file_mounts() - - assert len(mounts) == 2 - paths = {m["container_path"] for m in mounts} - assert "/root/.hermes/skill_token.json" in paths - assert "/root/.hermes/config_token.json" in paths - - -class TestGetMountsRechecksExistence: - def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - token = tmp_path / "token.json" - token.write_text("{}") - - register_credential_file("token.json") - assert len(get_credential_file_mounts()) == 1 - - # Delete the file after registration - token.unlink() + assert "does_not_exist.json" in missing assert get_credential_file_mounts() == [] + + def test_path_takes_precedence_over_name(self, tmp_path): + """When both path and name are present, path wins.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "real.json").write_text("{}") + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files([ + {"path": "real.json", "name": "wrong.json"}, + ]) + + assert missing == [] + mounts = get_credential_file_mounts() + assert "real.json" in mounts[0]["container_path"] + + +class TestSkillsDirectoryMount: + def test_returns_mount_when_skills_dir_exists(self, tmp_path): + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "test-skill").mkdir() + (skills_dir / "test-skill" / "SKILL.md").write_text("# test") + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount is not None + assert mount["host_path"] == str(skills_dir) + assert mount["container_path"] == "/root/.hermes/skills" + + def test_returns_none_when_no_skills_dir(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount is None + + def test_custom_container_base(self, tmp_path): + hermes_home = tmp_path / ".hermes" + (hermes_home / "skills").mkdir(parents=True) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount(container_base="/home/user/.hermes") + + assert mount["container_path"] == "/home/user/.hermes/skills" + + def test_symlinks_are_sanitized(self, tmp_path): + """Symlinks in skills dir should be excluded from the mount.""" + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "legit.md").write_text("# real skill") + # Create a symlink pointing outside the skills tree + secret = tmp_path / "secret.txt" + secret.write_text("TOP SECRET") + (skills_dir / "evil_link").symlink_to(secret) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount is not None + # The mount path should be a sanitized copy, not the original + safe_path = Path(mount["host_path"]) + assert safe_path != skills_dir + # Legitimate file should be present + assert (safe_path / "legit.md").exists() + assert (safe_path / "legit.md").read_text() == "# real skill" + # Symlink should NOT be present + assert not (safe_path / "evil_link").exists() + + def test_no_symlinks_returns_original_dir(self, tmp_path): + """When no symlinks exist, the original dir is returned (no copy).""" + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "skill.md").write_text("ok") + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mount = get_skills_directory_mount() + + assert mount["host_path"] == str(skills_dir) + + +class TestIterSkillsFiles: + def test_returns_files_skipping_symlinks(self, tmp_path): + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + (skills_dir / "cat" / "myskill").mkdir(parents=True) + (skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill") + (skills_dir / "cat" / "myskill" / "scripts").mkdir() + (skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash") + # Add a symlink that should be filtered + secret = tmp_path / "secret" + secret.write_text("nope") + (skills_dir / "cat" / "myskill" / "evil").symlink_to(secret) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + files = iter_skills_files() + + paths = {f["container_path"] for f in files} + assert "/root/.hermes/skills/cat/myskill/SKILL.md" in paths + assert "/root/.hermes/skills/cat/myskill/scripts/run.sh" in paths + # Symlink should be excluded + assert not any("evil" in f["container_path"] for f in files) + + def test_empty_when_no_skills_dir(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + assert iter_skills_files() == [] diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py index 94a28dc7f2..04e6347955 100644 --- a/tests/tools/test_daytona_environment.py +++ b/tests/tools/test_daytona_environment.py @@ -61,6 +61,10 @@ def make_env(daytona_sdk, monkeypatch): """Factory that creates a DaytonaEnvironment with a mocked SDK.""" # Prevent is_interrupted from interfering monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + # Prevent skills/credential sync from consuming mock exec calls + monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: []) + monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None) + monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: []) def _factory( sandbox=None, diff --git a/tools/credential_files.py b/tools/credential_files.py index 56c32d572a..53ddd79d54 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -83,7 +83,7 @@ def register_credential_files( if isinstance(entry, str): rel_path = entry.strip() elif isinstance(entry, dict): - rel_path = (entry.get("path") or "").strip() + rel_path = (entry.get("path") or entry.get("name") or "").strip() else: continue if not rel_path: @@ -152,6 +152,107 @@ def get_credential_file_mounts() -> List[Dict[str, str]]: ] +def get_skills_directory_mount( + container_base: str = "/root/.hermes", +) -> Dict[str, str] | None: + """Return mount info for a symlink-safe copy of the skills directory. + + Skills may include ``scripts/``, ``templates/``, and ``references/`` + subdirectories that the agent needs to execute inside remote sandboxes. + + **Security:** Bind mounts follow symlinks, so a malicious symlink inside + the skills tree could expose arbitrary host files to the container. When + symlinks are detected, this function creates a sanitized copy (regular + files only) in a temp directory and returns that path instead. When no + symlinks are present (the common case), the original directory is returned + directly with zero overhead. + + Returns a dict with ``host_path`` and ``container_path`` keys, or None. + """ + hermes_home = _resolve_hermes_home() + skills_dir = hermes_home / "skills" + if not skills_dir.is_dir(): + return None + + host_path = _safe_skills_path(skills_dir) + return { + "host_path": host_path, + "container_path": f"{container_base.rstrip('/')}/skills", + } + + +_safe_skills_tempdir: Path | None = None + + +def _safe_skills_path(skills_dir: Path) -> str: + """Return *skills_dir* if symlink-free, else a sanitized temp copy.""" + global _safe_skills_tempdir + + symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()] + if not symlinks: + return str(skills_dir) + + for link in symlinks: + logger.warning("credential_files: skipping symlink in skills dir: %s -> %s", + link, os.readlink(link)) + + import atexit + import shutil + import tempfile + + # Reuse the same temp dir across calls to avoid accumulation. + if _safe_skills_tempdir and _safe_skills_tempdir.is_dir(): + shutil.rmtree(_safe_skills_tempdir, ignore_errors=True) + + safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-")) + _safe_skills_tempdir = safe_dir + + for item in skills_dir.rglob("*"): + if item.is_symlink(): + continue + rel = item.relative_to(skills_dir) + target = safe_dir / rel + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + elif item.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(item), str(target)) + + def _cleanup(): + if safe_dir.is_dir(): + shutil.rmtree(safe_dir, ignore_errors=True) + + atexit.register(_cleanup) + logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir) + return str(safe_dir) + + +def iter_skills_files( + container_base: str = "/root/.hermes", +) -> List[Dict[str, str]]: + """Yield individual (host_path, container_path) entries for skills files. + + Skips symlinks entirely. Preferred for backends that upload files + individually (Daytona, Modal) rather than mounting a directory. + """ + hermes_home = _resolve_hermes_home() + skills_dir = hermes_home / "skills" + if not skills_dir.is_dir(): + return [] + + container_root = f"{container_base.rstrip('/')}/skills" + result: List[Dict[str, str]] = [] + for item in skills_dir.rglob("*"): + if item.is_symlink() or not item.is_file(): + continue + rel = item.relative_to(skills_dir) + result.append({ + "host_path": str(item), + "container_path": f"{container_root}/{rel}", + }) + return result + + def clear_credential_files() -> None: """Reset the skill-scoped registry (e.g. on session reset).""" _registered_files.clear() diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index cc046bb4d3..eb2a673110 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -113,15 +113,61 @@ class DaytonaEnvironment(BaseEnvironment): logger.info("Daytona: created sandbox %s for task %s", self._sandbox.id, task_id) - # Resolve cwd: detect actual home dir inside the sandbox - if self._requested_cwd in ("~", "/home/daytona"): - try: - home = self._sandbox.process.exec("echo $HOME").result.strip() - if home: + # Detect remote home dir first so mounts go to the right place. + self._remote_home = "/root" + try: + home = self._sandbox.process.exec("echo $HOME").result.strip() + if home: + self._remote_home = home + if self._requested_cwd in ("~", "/home/daytona"): self.cwd = home - except Exception: - pass # leave cwd as-is; sandbox will use its own default - logger.info("Daytona: resolved cwd to %s", self.cwd) + except Exception: + pass + logger.info("Daytona: resolved home to %s, cwd to %s", self._remote_home, self.cwd) + + # Track synced files to avoid redundant uploads. + # Key: remote_path, Value: (mtime, size) + self._synced_files: Dict[str, tuple] = {} + + # Upload credential files and skills directory into the sandbox. + self._sync_skills_and_credentials() + + def _upload_if_changed(self, host_path: str, remote_path: str) -> bool: + """Upload a file if its mtime/size changed since last sync.""" + hp = Path(host_path) + try: + stat = hp.stat() + file_key = (stat.st_mtime, stat.st_size) + except OSError: + return False + if self._synced_files.get(remote_path) == file_key: + return False + try: + parent = str(Path(remote_path).parent) + self._sandbox.process.exec(f"mkdir -p {parent}") + self._sandbox.fs.upload_file(host_path, remote_path) + self._synced_files[remote_path] = file_key + return True + except Exception as e: + logger.debug("Daytona: upload failed %s: %s", host_path, e) + return False + + def _sync_skills_and_credentials(self) -> None: + """Upload changed credential files and skill files into the sandbox.""" + container_base = f"{self._remote_home}/.hermes" + try: + from tools.credential_files import get_credential_file_mounts, iter_skills_files + + for mount_entry in get_credential_file_mounts(): + remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1) + if self._upload_if_changed(mount_entry["host_path"], remote_path): + logger.debug("Daytona: synced credential %s", remote_path) + + for entry in iter_skills_files(container_base=container_base): + if self._upload_if_changed(entry["host_path"], entry["container_path"]): + logger.debug("Daytona: synced skill %s", entry["container_path"]) + except Exception as e: + logger.debug("Daytona: could not sync skills/credentials: %s", e) def _ensure_sandbox_ready(self): """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" @@ -191,6 +237,9 @@ class DaytonaEnvironment(BaseEnvironment): stdin_data: Optional[str] = None) -> dict: with self._lock: self._ensure_sandbox_ready() + # Incremental sync before each command so mid-session credential + # refreshes and skill updates are picked up. + self._sync_skills_and_credentials() if stdin_data is not None: marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" diff --git a/tools/environments/docker.py b/tools/environments/docker.py index a24786d17e..2a7bb62551 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -315,7 +315,7 @@ class DockerEnvironment(BaseEnvironment): # Mount credential files (OAuth tokens, etc.) declared by skills. # Read-only so the container can authenticate but not modify host creds. try: - from tools.credential_files import get_credential_file_mounts + from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount for mount_entry in get_credential_file_mounts(): volume_args.extend([ @@ -327,6 +327,20 @@ class DockerEnvironment(BaseEnvironment): mount_entry["host_path"], mount_entry["container_path"], ) + + # Mount the skills directory so skill scripts/templates are + # available inside the container at the same relative path. + skills_mount = get_skills_directory_mount() + if skills_mount: + volume_args.extend([ + "-v", + f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro", + ]) + logger.info( + "Docker: mounting skills dir %s -> %s", + skills_mount["host_path"], + skills_mount["container_path"], + ) except Exception as e: logger.debug("Docker: could not load credential file mounts: %s", e) diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 2842bed114..89e8f4776a 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -142,7 +142,7 @@ class ModalEnvironment(BaseEnvironment): # external services but can't modify the host's credentials. cred_mounts = [] try: - from tools.credential_files import get_credential_file_mounts + from tools.credential_files import get_credential_file_mounts, iter_skills_files for mount_entry in get_credential_file_mounts(): cred_mounts.append( @@ -156,6 +156,18 @@ class ModalEnvironment(BaseEnvironment): mount_entry["host_path"], mount_entry["container_path"], ) + + # Mount individual skill files (symlinks filtered out). + skills_files = iter_skills_files() + for entry in skills_files: + cred_mounts.append( + _modal.Mount.from_local_file( + entry["host_path"], + remote_path=entry["container_path"], + ) + ) + if skills_files: + logger.info("Modal: mounting %d skill files", len(skills_files)) except Exception as e: logger.debug("Modal: could not load credential file mounts: %s", e) @@ -184,72 +196,69 @@ class ModalEnvironment(BaseEnvironment): self._app, self._sandbox = self._worker.run_coroutine( _create_sandbox(), timeout=300 ) - # Track synced credential files to avoid redundant pushes. + # Track synced files to avoid redundant pushes. # Key: container_path, Value: (mtime, size) of last synced version. - self._synced_creds: Dict[str, tuple] = {} + self._synced_files: Dict[str, tuple] = {} logger.info("Modal: sandbox created (task=%s)", self._task_id) - def _sync_credential_files(self) -> None: - """Push credential files into the running sandbox. + def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool: + """Push a single file into the sandbox if changed. Returns True if synced.""" + hp = Path(host_path) + try: + stat = hp.stat() + file_key = (stat.st_mtime, stat.st_size) + except OSError: + return False - Mounts are set at sandbox creation, but credentials may be created - later (e.g. OAuth setup mid-session). This writes the current file - content into the sandbox via exec(), so new/updated credentials are - available without recreating the sandbox. + if self._synced_files.get(container_path) == file_key: + return False + + try: + content = hp.read_bytes() + except Exception: + return False + + import base64 + b64 = base64.b64encode(content).decode("ascii") + container_dir = str(Path(container_path).parent) + cmd = ( + f"mkdir -p {shlex.quote(container_dir)} && " + f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}" + ) + + async def _write(): + proc = await self._sandbox.exec.aio("bash", "-c", cmd) + await proc.wait.aio() + + self._worker.run_coroutine(_write(), timeout=15) + self._synced_files[container_path] = file_key + return True + + def _sync_files(self) -> None: + """Push credential files and skill files into the running sandbox. + + Runs before each command. Uses mtime+size caching so only changed + files are pushed (~13μs overhead in the no-op case). """ try: - from tools.credential_files import get_credential_file_mounts + from tools.credential_files import get_credential_file_mounts, iter_skills_files - mounts = get_credential_file_mounts() - if not mounts: - return + for entry in get_credential_file_mounts(): + if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]): + logger.debug("Modal: synced credential %s", entry["container_path"]) - for entry in mounts: - host_path = entry["host_path"] - container_path = entry["container_path"] - hp = Path(host_path) - try: - stat = hp.stat() - file_key = (stat.st_mtime, stat.st_size) - except OSError: - continue - - # Skip if already synced with same mtime+size - if self._synced_creds.get(container_path) == file_key: - continue - - try: - content = hp.read_text(encoding="utf-8") - except Exception: - continue - - # Write via base64 to avoid shell escaping issues with JSON - import base64 - b64 = base64.b64encode(content.encode("utf-8")).decode("ascii") - container_dir = str(Path(container_path).parent) - cmd = ( - f"mkdir -p {shlex.quote(container_dir)} && " - f"echo {shlex.quote(b64)} | base64 -d > {shlex.quote(container_path)}" - ) - - _cp = container_path # capture for closure - - async def _write(): - proc = await self._sandbox.exec.aio("bash", "-c", cmd) - await proc.wait.aio() - - self._worker.run_coroutine(_write(), timeout=15) - self._synced_creds[container_path] = file_key - logger.debug("Modal: synced credential %s -> %s", host_path, container_path) + for entry in iter_skills_files(): + if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]): + logger.debug("Modal: synced skill file %s", entry["container_path"]) except Exception as e: - logger.debug("Modal: credential file sync failed: %s", e) + logger.debug("Modal: file sync failed: %s", e) def execute(self, command: str, cwd: str = "", *, timeout: int | None = None, stdin_data: str | None = None) -> dict: # Sync credential files before each command so mid-session # OAuth setups are picked up without requiring a restart. - self._sync_credential_files() + self._sync_files() if stdin_data is not None: marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py index 72afbac5fd..381ac2b2d6 100644 --- a/tools/environments/singularity.py +++ b/tools/environments/singularity.py @@ -254,6 +254,28 @@ class SingularityEnvironment(BaseEnvironment): else: cmd.append("--writable-tmpfs") + # Mount credential files and skills directory (read-only). + try: + from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount + + for mount_entry in get_credential_file_mounts(): + cmd.extend(["--bind", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro"]) + logger.info( + "Singularity: binding credential %s -> %s", + mount_entry["host_path"], + mount_entry["container_path"], + ) + skills_mount = get_skills_directory_mount() + if skills_mount: + cmd.extend(["--bind", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro"]) + logger.info( + "Singularity: binding skills dir %s -> %s", + skills_mount["host_path"], + skills_mount["container_path"], + ) + except Exception as e: + logger.debug("Singularity: could not load credential/skills mounts: %s", e) + # Resource limits (cgroup-based, may require root or appropriate config) if self._memory > 0: cmd.extend(["--memory", f"{self._memory}M"]) diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index fa3781a990..94b0a6b3f0 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -55,6 +55,8 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment): self.control_socket = self.control_dir / f"{user}@{host}:{port}.sock" _ensure_ssh_available() self._establish_connection() + self._remote_home = self._detect_remote_home() + self._sync_skills_and_credentials() if self.persistent: self._init_persistent_shell() @@ -87,6 +89,79 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment): except subprocess.TimeoutExpired: raise RuntimeError(f"SSH connection to {self.user}@{self.host} timed out") + def _detect_remote_home(self) -> str: + """Detect the remote user's home directory.""" + try: + cmd = self._build_ssh_command() + cmd.append("echo $HOME") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + home = result.stdout.strip() + if home and result.returncode == 0: + logger.debug("SSH: remote home = %s", home) + return home + except Exception: + pass + # Fallback: guess from username + if self.user == "root": + return "/root" + return f"/home/{self.user}" + + def _sync_skills_and_credentials(self) -> None: + """Rsync skills directory and credential files to the remote host.""" + try: + container_base = f"{self._remote_home}/.hermes" + from tools.credential_files import get_credential_file_mounts, get_skills_directory_mount + + rsync_base = ["rsync", "-az", "--timeout=30", "--safe-links"] + ssh_opts = f"ssh -o ControlPath={self.control_socket} -o ControlMaster=auto" + if self.port != 22: + ssh_opts += f" -p {self.port}" + if self.key_path: + ssh_opts += f" -i {self.key_path}" + rsync_base.extend(["-e", ssh_opts]) + dest_prefix = f"{self.user}@{self.host}" + + # Sync individual credential files (remap /root/.hermes to detected home) + for mount_entry in get_credential_file_mounts(): + remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1) + parent_dir = str(Path(remote_path).parent) + mkdir_cmd = self._build_ssh_command() + mkdir_cmd.append(f"mkdir -p {parent_dir}") + subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10) + cmd = rsync_base + [mount_entry["host_path"], f"{dest_prefix}:{remote_path}"] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + logger.info("SSH: synced credential %s -> %s", mount_entry["host_path"], remote_path) + else: + logger.debug("SSH: rsync credential failed: %s", result.stderr.strip()) + + # Sync skills directory (remap to detected home) + skills_mount = get_skills_directory_mount(container_base=container_base) + if skills_mount: + remote_path = skills_mount["container_path"] + mkdir_cmd = self._build_ssh_command() + mkdir_cmd.append(f"mkdir -p {remote_path}") + subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=10) + cmd = rsync_base + [ + skills_mount["host_path"].rstrip("/") + "/", + f"{dest_prefix}:{remote_path}/", + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + logger.info("SSH: synced skills dir %s -> %s", skills_mount["host_path"], remote_path) + else: + logger.debug("SSH: rsync skills dir failed: %s", result.stderr.strip()) + except Exception as e: + logger.debug("SSH: could not sync skills/credentials: %s", e) + + def execute(self, command: str, cwd: str = "", *, + timeout: int | None = None, + stdin_data: str | None = None) -> dict: + # Incremental sync before each command so mid-session credential + # refreshes and skill updates are picked up. + self._sync_skills_and_credentials() + return super().execute(command, cwd, timeout=timeout, stdin_data=stdin_data) + _poll_interval_start: float = 0.15 # SSH: higher initial interval (150ms) for network latency @property From efae525dc5c70523527bcd3ed2c15630f0744daa Mon Sep 17 00:00:00 2001 From: Wing Lian Date: Mon, 30 Mar 2026 05:48:06 -0400 Subject: [PATCH 19/52] feat(plugins): add inject_message interface for remote message injection (#3778) --- cli.py | 5 +++++ hermes_cli/plugins.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/cli.py b/cli.py index 3371b98213..4c76ff142a 100644 --- a/cli.py +++ b/cli.py @@ -6210,6 +6210,11 @@ class HermesCLI: self._interrupt_queue = queue.Queue() # For messages typed while agent is running self._should_exit = False self._last_ctrl_c_time = 0 # Track double Ctrl+C for force exit + + # Give plugin manager a CLI reference so plugins can inject messages + from hermes_cli.plugins import get_plugin_manager + get_plugin_manager()._cli_ref = self + # Config file watcher — detect mcp_servers changes and auto-reload from hermes_cli.config import get_config_path as _get_config_path _cfg_path = _get_config_path() diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index c72bc59e7c..0146014f3e 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -152,6 +152,34 @@ class PluginContext: self._manager._plugin_tool_names.add(name) logger.debug("Plugin %s registered tool: %s", self.manifest.name, name) + # -- message injection -------------------------------------------------- + + def inject_message(self, content: str, role: str = "user") -> bool: + """Inject a message into the active conversation. + + If the agent is idle (waiting for user input), this starts a new turn. + If the agent is running, this interrupts and injects the message. + + This enables plugins (e.g. remote control viewers, messaging bridges) + to send messages into the conversation from external sources. + + Returns True if the message was queued successfully. + """ + cli = self._manager._cli_ref + if cli is None: + logger.warning("inject_message: no CLI reference (not available in gateway mode)") + return False + + msg = content if role == "user" else f"[{role}] {content}" + + if getattr(cli, "_agent_running", False): + # Agent is mid-turn — interrupt with the message + cli._interrupt_queue.put(msg) + else: + # Agent is idle — queue as next input + cli._pending_input.put(msg) + return True + # -- hook registration -------------------------------------------------- def register_hook(self, hook_name: str, callback: Callable) -> None: @@ -184,6 +212,7 @@ class PluginManager: self._hooks: Dict[str, List[Callable]] = {} self._plugin_tool_names: Set[str] = set() self._discovered: bool = False + self._cli_ref = None # Set by CLI after plugin discovery # ----------------------------------------------------------------------- # Public From 0e592aa5b4d38680233f499c426b9578a2765a1a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:53:21 -0700 Subject: [PATCH 20/52] fix(cli): remove input() from /tools disable that freezes the terminal (#3918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input() hangs inside prompt_toolkit's TUI event loop — this is a known pitfall (AGENTS.md). The /tools disable and /tools enable commands used input() for a Y/N confirmation prompt, causing the terminal to freeze with no way to type a response. Fix: remove the confirmation prompt. The user typing '/tools disable web' is implicit consent. The change is applied directly with a status message. --- cli.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/cli.py b/cli.py index 4c76ff142a..706221506c 100644 --- a/cli.py +++ b/cli.py @@ -2789,22 +2789,12 @@ class HermesCLI: print(f" MCP tool: /tools {subcommand} github:create_issue") return - # Confirm session reset before applying - verb = "Disable" if subcommand == "disable" else "Enable" + # Apply the change directly — the user typing the command is implicit + # consent. Do NOT use input() here; it hangs inside prompt_toolkit's + # TUI event loop (known pitfall). + verb = "Disabling" if subcommand == "disable" else "Enabling" label = ", ".join(names) - _cprint(f"{_GOLD}{verb} {label}?{_RST}") - _cprint(f"{_DIM}This will save to config and reset your session so the " - f"change takes effect cleanly.{_RST}") - try: - answer = input(" Continue? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print() - _cprint(f"{_DIM}Cancelled.{_RST}") - return - - if answer not in ("y", "yes"): - _cprint(f"{_DIM}Cancelled.{_RST}") - return + _cprint(f"{_GOLD}{verb} {label}...{_RST}") tools_disable_enable_command( Namespace(tools_action=subcommand, names=names, platform="cli")) From d028a94b83e4c696f0aee5ed0739574fa4516a59 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:55:33 -0700 Subject: [PATCH 21/52] =?UTF-8?q?fix(whatsapp):=20skip=20reply=20prefix=20?= =?UTF-8?q?in=20bot=20mode=20=E2=80=94=20only=20needed=20for=20self-chat?= =?UTF-8?q?=20(#3931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WhatsApp bridge prepends '⚕ *Hermes Agent*\n────────────\n' to every outgoing message. In self-chat mode this is necessary to distinguish the bot's responses from the user's own messages. In bot mode the messages already come from a different number, making the prefix redundant and cluttered. Now only prepends the prefix when WHATSAPP_MODE is 'self-chat' (the default). Bot mode messages are sent clean. --- scripts/whatsapp-bridge/bridge.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 46cc5c339e..5f0cb729f6 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -55,6 +55,10 @@ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); function formatOutgoingMessage(message) { + // In bot mode, messages come from a different number so the prefix is + // redundant — the sender identity is already clear. Only prepend in + // self-chat mode where bot and user share the same number. + if (WHATSAPP_MODE !== 'self-chat') return message; return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message; } From b4496b33b59de8b66b6b681581d2e21ab6b4deb9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:59:39 -0700 Subject: [PATCH 22/52] fix: background task media delivery + vision download timeout (#3919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(telegram): add webhook mode as alternative to polling When TELEGRAM_WEBHOOK_URL is set, the adapter starts an HTTP webhook server (via python-telegram-bot's start_webhook()) instead of long polling. This enables cloud platforms like Fly.io and Railway to auto-wake suspended machines on inbound HTTP traffic. Polling remains the default — no behavior change unless the env var is set. Env vars: TELEGRAM_WEBHOOK_URL Public HTTPS URL for Telegram to push to TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) TELEGRAM_WEBHOOK_SECRET Secret token for update verification Cherry-picked and adapted from PR #2022 by SHL0MS. Preserved all current main enhancements (network error recovery, polling conflict detection, DM topics setup). Co-authored-by: SHL0MS * fix: send_document call in background task delivery + vision download timeout Two fixes salvaged from PR #2269 by amethystani: 1. gateway/run.py: adapter.send_file() → adapter.send_document() send_file() doesn't exist on BasePlatformAdapter. Background task media files were silently never delivered (AttributeError swallowed by except Exception: pass). 2. tools/vision_tools.py: configurable image download timeout via HERMES_VISION_DOWNLOAD_TIMEOUT env var (default 30s), plus guard against raise None when max_retries=0. The third fix in #2269 (opencode-go auth config) was already resolved on main. Co-authored-by: amethystani --------- Co-authored-by: SHL0MS Co-authored-by: amethystani --- cli-config.yaml.example | 3 +++ gateway/run.py | 2 +- hermes_cli/config.py | 3 ++- tools/vision_tools.py | 28 +++++++++++++++++++++++- website/docs/user-guide/configuration.md | 5 +++-- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 504b2178d8..922807f17a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -324,6 +324,9 @@ compression: # vision: # provider: "auto" # model: "" # e.g. "google/gemini-2.5-flash", "openai/gpt-4o" +# timeout: 30 # LLM API call timeout (seconds) +# download_timeout: 30 # Image HTTP download timeout (seconds) +# # Increase for slow connections or self-hosted image servers # # # Web page scraping / summarization + browser page text extraction # web_extract: diff --git a/gateway/run.py b/gateway/run.py index 3c0ca18181..3b51930421 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3891,7 +3891,7 @@ class GatewayRunner: # Send media files for media_path in (media_files or []): try: - await adapter.send_file( + await adapter.send_document( chat_id=source.chat_id, file_path=media_path, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 69530761dc..e2503ebec2 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -223,7 +223,8 @@ DEFAULT_CONFIG = { "model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o" "base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider) "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) - "timeout": 30, # seconds — increase for slow local vision models + "timeout": 30, # seconds — LLM API call timeout; increase for slow local vision models + "download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections }, "web_extract": { "provider": "auto", diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 47b406846e..404d06a500 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -45,6 +45,28 @@ logger = logging.getLogger(__name__) _debug = DebugSession("vision_tools", env_var="VISION_TOOLS_DEBUG") +# Configurable HTTP download timeout for _download_image(). +# Separate from auxiliary.vision.timeout which governs the LLM API call. +# Resolution: config.yaml auxiliary.vision.download_timeout → env var → 30s default. +def _resolve_download_timeout() -> float: + env_val = os.getenv("HERMES_VISION_DOWNLOAD_TIMEOUT", "").strip() + if env_val: + try: + return float(env_val) + except ValueError: + pass + try: + from hermes_cli.config import load_config + cfg = load_config() + val = cfg.get("auxiliary", {}).get("vision", {}).get("download_timeout") + if val is not None: + return float(val) + except Exception: + pass + return 30.0 + +_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout() + def _validate_image_url(url: str) -> bool: """ @@ -146,7 +168,7 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = # Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum) # SSRF: event_hooks validates each redirect target against private IP ranges async with httpx.AsyncClient( - timeout=30.0, + timeout=_VISION_DOWNLOAD_TIMEOUT, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, ) as client: @@ -183,6 +205,10 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = exc_info=True, ) + if last_error is None: + raise RuntimeError( + f"_download_image exited retry loop without attempting (max_retries={max_retries})" + ) raise last_error diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 17f15dc5c4..48d76dd80b 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1018,7 +1018,8 @@ auxiliary: model: "" # e.g. "openai/gpt-4o", "google/gemini-2.5-flash" base_url: "" # Custom OpenAI-compatible endpoint (overrides provider) api_key: "" # API key for base_url (falls back to OPENAI_API_KEY) - timeout: 30 # seconds — increase for slow local vision models + timeout: 30 # seconds — LLM API call; increase for slow local vision models + download_timeout: 30 # seconds — image HTTP download; increase for slow connections # Web page summarization + browser page text extraction web_extract: @@ -1042,7 +1043,7 @@ auxiliary: ``` :::tip -Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. +Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision 30s, web_extract 30s, approval 30s, compression 120s. Increase these if you use slow local models for auxiliary tasks. Vision also has a separate `download_timeout` (default 30s) for the HTTP image download — increase this for slow connections or self-hosted image servers. ::: :::info From 0b0c1b326c4db8c7a421473863c2a3fcbad76aea Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:02:28 +0300 Subject: [PATCH 23/52] fix: openclaw migration overwrites model config dict with string (#3924) migrate_model_config() was writing `config["model"] = model_str` which replaces the entire model dict (default, provider, base_url) with a bare string. This causes 'str' object has no attribute 'get' errors throughout Hermes when any code does model_cfg.get("default"). Now preserves the existing model dict and only updates the "default" key, keeping provider/base_url intact. --- .../openclaw-migration/scripts/openclaw_to_hermes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index f2e3d7af68..ac99e2a6fd 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -1297,7 +1297,11 @@ class Migrator: if self.execute: backup_path = self.maybe_backup(destination) - hermes_config["model"] = model_str + existing_model = hermes_config.get("model") + if isinstance(existing_model, dict): + existing_model["default"] = model_str + else: + hermes_config["model"] = {"default": model_str} dump_yaml_file(destination, hermes_config) self.record("model-config", source_path, destination, "migrated", backup=str(backup_path) if backup_path else "", model=model_str) else: From 1e896b0251c3eaafa2d22c6fe730e9697f583171 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:10:14 -0700 Subject: [PATCH 24/52] fix: resolve 7 failing CI tests (#3936) 1. matrix voice: _on_room_message_media unconditionally overwrote media_urls with the image cache path (always None for non-images), wiping the locally-cached voice path. Now only overrides when cached_path is truthy. 2. cli_tools_command: /tools disable no longer prompts for confirmation (input() removed in earlier commit to fix TUI hang), but tests still expected the old Y/N prompt flow. Updated tests to match current behavior (direct apply + session reset). 3. slack app_mention: connect() was refactored for multi-workspace (creates AsyncWebClient per token), but test only mocked the old self._app.client path. Added AsyncWebClient and acquire_scoped_lock mocks. 4. website_policy: module-level _cached_policy from earlier tests caused fast-path return of None. Added invalidate_cache() before assertion. 5. codex 401 refresh: already passing on current main (fixed by intervening commit). --- gateway/platforms/matrix.py | 5 ++-- tests/gateway/test_slack.py | 11 ++++++++ tests/test_cli_tools_command.py | 41 ++++++++++++++++++------------ tests/tools/test_website_policy.py | 6 +++++ 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index d8593a3537..309baeee73 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -904,8 +904,9 @@ class MatrixAdapter(BasePlatformAdapter): thread_id=thread_id, ) - # Use cached local path for images, HTTP URL for other media types - media_urls = [cached_path] if cached_path else ([http_url] if http_url else None) + # Use cached local path for images (voice messages already handled above). + if cached_path: + media_urls = [cached_path] media_types = [media_type] if media_urls else None msg_event = MessageEvent( diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index f4e687f0f0..16924b5901 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -126,9 +126,20 @@ class TestAppMentionHandler: "user": "testbot", }) + # Mock AsyncWebClient so multi-workspace auth_test is awaitable + mock_web_client = AsyncMock() + mock_web_client.auth_test = AsyncMock(return_value={ + "user_id": "U_BOT", + "user": "testbot", + "team_id": "T_FAKE", + "team": "FakeTeam", + }) + with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ + patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ patch("asyncio.create_task"): asyncio.run(adapter.connect()) diff --git a/tests/test_cli_tools_command.py b/tests/test_cli_tools_command.py index 9e648aecbf..2f0b096d2e 100644 --- a/tests/test_cli_tools_command.py +++ b/tests/test_cli_tools_command.py @@ -60,34 +60,43 @@ class TestToolsSlashList: class TestToolsSlashDisableWithReset: - def test_disable_confirms_then_resets_session(self): + def test_disable_applies_directly_and_resets_session(self): + """Disable applies immediately (no confirmation prompt) and resets session.""" cli_obj = _make_cli(["web", "memory"]) with patch("hermes_cli.tools_config.load_config", return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ patch("hermes_cli.tools_config.save_config"), \ patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ patch("hermes_cli.config.load_config", return_value={}), \ - patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", return_value="y"): + patch.object(cli_obj, "new_session") as mock_reset: cli_obj._handle_tools_command("/tools disable web") mock_reset.assert_called_once() assert "web" not in cli_obj.enabled_toolsets - def test_disable_cancelled_does_not_reset(self): + def test_disable_does_not_prompt_for_confirmation(self): + """Disable no longer uses input() — it applies directly.""" cli_obj = _make_cli(["web", "memory"]) - with patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", return_value="n"): + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ + patch("hermes_cli.tools_config.save_config"), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(cli_obj, "new_session"), \ + patch("builtins.input") as mock_input: cli_obj._handle_tools_command("/tools disable web") - mock_reset.assert_not_called() - # Toolsets unchanged - assert cli_obj.enabled_toolsets == {"web", "memory"} + mock_input.assert_not_called() - def test_disable_eof_cancels(self): + def test_disable_always_resets_session(self): + """Even without a confirmation prompt, disable always resets the session.""" cli_obj = _make_cli(["web", "memory"]) - with patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", side_effect=EOFError): + with patch("hermes_cli.tools_config.load_config", + return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \ + patch("hermes_cli.tools_config.save_config"), \ + patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(cli_obj, "new_session") as mock_reset: cli_obj._handle_tools_command("/tools disable web") - mock_reset.assert_not_called() + mock_reset.assert_called_once() def test_disable_missing_name_prints_usage(self, capsys): cli_obj = _make_cli() @@ -101,15 +110,15 @@ class TestToolsSlashDisableWithReset: class TestToolsSlashEnableWithReset: - def test_enable_confirms_then_resets_session(self): + def test_enable_applies_directly_and_resets_session(self): + """Enable applies immediately (no confirmation prompt) and resets session.""" cli_obj = _make_cli(["memory"]) with patch("hermes_cli.tools_config.load_config", return_value={"platform_toolsets": {"cli": ["memory"]}}), \ patch("hermes_cli.tools_config.save_config"), \ patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \ patch("hermes_cli.config.load_config", return_value={}), \ - patch.object(cli_obj, "new_session") as mock_reset, \ - patch("builtins.input", return_value="y"): + patch.object(cli_obj, "new_session") as mock_reset: cli_obj._handle_tools_command("/tools enable web") mock_reset.assert_called_once() assert "web" in cli_obj.enabled_toolsets diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 4312d970e2..4573e02765 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -259,6 +259,12 @@ def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Invalidate the module-level cache so the new HERMES_HOME is picked up. + # A prior test may have cached a default policy (enabled=False) under the + # old HERMES_HOME set by the autouse _isolate_hermes_home fixture. + from tools.website_policy import invalidate_cache + invalidate_cache() + blocked = check_website_access("https://dynamic.example/path") assert blocked is not None From 74181fe726e2e2c11e5c3e72032d3043586704db Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:10:23 -0700 Subject: [PATCH 25/52] fix: add TTY guard to interactive CLI commands to prevent CPU spin (#3933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When interactive TUI commands are invoked non-interactively (e.g. via the agent's terminal() tool through a subprocess pipe), curses loops spin at 100% CPU and input() calls hang indefinitely. Defense in depth — two layers: 1. Source-level guard in curses_checklist() (curses_ui.py + checklist.py): Returns cancel_returns immediately when stdin is not a TTY. This catches ALL callers automatically, including future code. 2. Command-level guards with clear error messages: - hermes tools (interactive checklist, not list/disable/enable) - hermes setup (interactive wizard) - hermes model (provider/model picker) - hermes whatsapp (pairing setup) - hermes skills config (skill toggle) - hermes mcp configure (tool selection) - hermes uninstall (confirmation prompt) Non-interactive subcommands (hermes tools list, hermes tools enable, hermes mcp add/remove/list/test, hermes skills search/install/browse) remain unaffected. --- hermes_cli/checklist.py | 5 +++++ hermes_cli/curses_ui.py | 6 ++++++ hermes_cli/main.py | 23 +++++++++++++++++++++++ hermes_cli/mcp_config.py | 4 ++++ 4 files changed, 38 insertions(+) diff --git a/hermes_cli/checklist.py b/hermes_cli/checklist.py index 1c56725aae..1a8d9720aa 100644 --- a/hermes_cli/checklist.py +++ b/hermes_cli/checklist.py @@ -5,6 +5,7 @@ toggleable list of items. Falls back to a numbered text UI when curses is unavailable (Windows without curses, piped stdin, etc.). """ +import sys from typing import List, Set from hermes_cli.colors import Colors, color @@ -26,6 +27,10 @@ def curses_checklist( The indices the user confirmed as checked. On cancel (ESC/q), returns ``pre_selected`` unchanged. """ + # Safety: return defaults when stdin is not a terminal. + if not sys.stdin.isatty(): + return set(pre_selected) + try: import curses selected = set(pre_selected) diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index dce620b8c6..c4b79091e8 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -4,6 +4,7 @@ Used by `hermes tools` and `hermes skills` for interactive checklists. Provides a curses multi-select with keyboard navigation, plus a text-based numbered fallback for terminals without curses support. """ +import sys from typing import Callable, List, Optional, Set from hermes_cli.colors import Colors, color @@ -31,6 +32,11 @@ def curses_checklist( if cancel_returns is None: cancel_returns = set(selected) + # Safety: curses and input() both hang or spin when stdin is not a + # terminal (e.g. subprocess pipe). Return defaults immediately. + if not sys.stdin.isatty(): + return cancel_returns + try: import curses chosen = set(selected) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 39d233ad46..f6d7d7c712 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -50,6 +50,23 @@ import sys from pathlib import Path from typing import Optional +def _require_tty(command_name: str) -> None: + """Exit with a clear error if stdin is not a terminal. + + Interactive TUI commands (hermes tools, hermes setup, hermes model) use + curses or input() prompts that spin at 100% CPU when stdin is a pipe. + This guard prevents accidental non-interactive invocation. + """ + if not sys.stdin.isatty(): + print( + f"Error: 'hermes {command_name}' requires an interactive terminal.\n" + f"It cannot be run through a pipe or non-interactive subprocess.\n" + f"Run it directly in your terminal instead.", + file=sys.stderr, + ) + sys.exit(1) + + # Add project root to path PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) @@ -617,6 +634,7 @@ def cmd_gateway(args): def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" + _require_tty("whatsapp") import subprocess from pathlib import Path from hermes_cli.config import get_env_value, save_env_value @@ -803,12 +821,14 @@ def cmd_whatsapp(args): def cmd_setup(args): """Interactive setup wizard.""" + _require_tty("setup") from hermes_cli.setup import run_setup_wizard run_setup_wizard(args) def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" + _require_tty("model") from hermes_cli.auth import ( resolve_provider, AuthError, format_auth_error, ) @@ -2459,6 +2479,7 @@ def cmd_version(args): def cmd_uninstall(args): """Uninstall Hermes Agent.""" + _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall run_uninstall(args) @@ -4131,6 +4152,7 @@ For more help on a command: def cmd_skills(args): # Route 'config' action to skills_config module if getattr(args, 'skills_action', None) == 'config': + _require_tty("skills config") from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) else: @@ -4341,6 +4363,7 @@ For more help on a command: from hermes_cli.tools_config import tools_disable_enable_command tools_disable_enable_command(args) else: + _require_tty("tools") from hermes_cli.tools_config import tools_command tools_command(args) diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0f08e46739..9154ed50a3 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -511,6 +511,10 @@ def _interpolate_value(value: str) -> str: def cmd_mcp_configure(args): """Reconfigure which tools are enabled for an existing MCP server.""" + import sys as _sys + if not _sys.stdin.isatty(): + print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr) + _sys.exit(1) name = args.name servers = _get_mcp_servers() From fb634068df03185083c26349cb63d423d732fa50 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:13:01 -0700 Subject: [PATCH 26/52] fix(security): extend secret redaction to ElevenLabs, Tavily and Exa API keys (#3920) ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys were not covered by _PREFIX_PATTERNS, leaking in plain text via printenv or log output. Salvaged from PR #3790 by @memosr. Tests rewritten with correct assertions (original tests had vacuously true checks). Co-authored-by: memosr --- agent/redact.py | 3 +++ tests/agent/test_redact.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/agent/redact.py b/agent/redact.py index d298ffb030..895e3265fd 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -37,6 +37,9 @@ _PREFIX_PATTERNS = [ r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key + r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash) + r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key + r"exa_[A-Za-z0-9]{10,}", # Exa search API key ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 2ab6b0ea4a..27098ee6dc 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -201,3 +201,52 @@ class TestSecretCapturePayloadRedaction: text = '{"raw_secret": "ghp_abc123def456ghi789jkl"}' result = redact_sensitive_text(text) assert "abc123def456" not in result + + +class TestElevenLabsTavilyExaKeys: + """Regression tests for ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys.""" + + def test_elevenlabs_key_redacted(self): + text = "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu" + result = redact_sensitive_text(text) + assert "abc123def456ghi" not in result + + def test_elevenlabs_key_in_log_line(self): + text = "Connecting to ElevenLabs with key sk_abc123def456ghi789jklmnopqrstu" + result = redact_sensitive_text(text) + assert "abc123def456ghi" not in result + + def test_tavily_key_redacted(self): + text = "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000" + result = redact_sensitive_text(text) + assert "ABCdef123456789" not in result + + def test_tavily_key_in_log_line(self): + text = "Initialising Tavily client with tvly-ABCdef123456789GHIJKL0000" + result = redact_sensitive_text(text) + assert "ABCdef123456789" not in result + + def test_exa_key_redacted(self): + text = "EXA_API_KEY=exa_XYZ789abcdef000000000000000" + result = redact_sensitive_text(text) + assert "XYZ789abcdef" not in result + + def test_exa_key_in_log_line(self): + text = "Using Exa client with key exa_XYZ789abcdef000000000000000" + result = redact_sensitive_text(text) + assert "XYZ789abcdef" not in result + + def test_all_three_in_env_dump(self): + env_dump = ( + "HOME=/home/user\n" + "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu\n" + "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000\n" + "EXA_API_KEY=exa_XYZ789abcdef000000000000000\n" + "SHELL=/bin/bash\n" + ) + result = redact_sensitive_text(env_dump) + assert "abc123def456ghi" not in result + assert "ABCdef123456789" not in result + assert "XYZ789abcdef" not in result + assert "HOME=/home/user" in result + assert "SHELL=/bin/bash" in result From e08778fa1ee377f7128641f8cc03b0de046bd8da Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:29:38 -0700 Subject: [PATCH 27/52] chore: release v0.6.0 (2026.3.30) (#3985) --- RELEASE_v0.6.0.md | 249 +++++++++++++++++++++++++++++++++++++++++ hermes_cli/__init__.py | 4 +- pyproject.toml | 2 +- 3 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 RELEASE_v0.6.0.md diff --git a/RELEASE_v0.6.0.md b/RELEASE_v0.6.0.md new file mode 100644 index 0000000000..5bef7c6c51 --- /dev/null +++ b/RELEASE_v0.6.0.md @@ -0,0 +1,249 @@ +# Hermes Agent v0.6.0 (v2026.3.30) + +**Release Date:** March 30, 2026 + +> The multi-instance release — Profiles for running isolated agent instances, MCP server mode, Docker container, fallback provider chains, two new messaging platforms (Feishu/Lark and WeCom), Telegram webhook mode, Slack multi-workspace OAuth, 95 PRs and 16 resolved issues in 2 days. + +--- + +## ✨ Highlights + +- **Profiles — Multi-Instance Hermes** — Run multiple isolated Hermes instances from the same installation. Each profile gets its own config, memory, sessions, skills, and gateway service. Create with `hermes profile create`, switch with `hermes -p `, export/import for sharing. Full token-lock isolation prevents two profiles from using the same bot credential. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681)) + +- **MCP Server Mode** — Expose Hermes conversations and sessions to any MCP-compatible client (Claude Desktop, Cursor, VS Code, etc.) via `hermes mcp serve`. Browse conversations, read messages, search across sessions, and manage attachments — all through the Model Context Protocol. Supports both stdio and Streamable HTTP transports. ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795)) + +- **Docker Container** — Official Dockerfile for running Hermes Agent in a container. Supports both CLI and gateway modes with volume-mounted config. ([#3668](https://github.com/NousResearch/hermes-agent/pull/3668), closes [#850](https://github.com/NousResearch/hermes-agent/issues/850)) + +- **Ordered Fallback Provider Chain** — Configure multiple inference providers with automatic failover. When your primary provider returns errors or is unreachable, Hermes automatically tries the next provider in the chain. Configure via `fallback_providers` in config.yaml. ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813), closes [#1734](https://github.com/NousResearch/hermes-agent/issues/1734)) + +- **Feishu/Lark Platform Support** — Full gateway adapter for Feishu (飞书) and Lark with event subscriptions, message cards, group chat, image/file attachments, and interactive card callbacks. ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817), closes [#1788](https://github.com/NousResearch/hermes-agent/issues/1788)) + +- **WeCom (Enterprise WeChat) Platform Support** — New gateway adapter for WeCom (企业微信) with text/image/voice messages, group chats, and callback verification. ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847)) + +- **Slack Multi-Workspace OAuth** — Connect a single Hermes gateway to multiple Slack workspaces via OAuth token file. Each workspace gets its own bot token, resolved dynamically per incoming event. ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903)) + +- **Telegram Webhook Mode & Group Controls** — Run the Telegram adapter in webhook mode as an alternative to polling — faster response times and better for production deployments behind a reverse proxy. New group mention gating controls when the bot responds: always, only when @mentioned, or via regex triggers. ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880), [#3870](https://github.com/NousResearch/hermes-agent/pull/3870)) + +- **Exa Search Backend** — Add Exa as an alternative web search and content extraction backend alongside Firecrawl and DuckDuckGo. Set `EXA_API_KEY` and configure as preferred backend. ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648)) + +- **Skills & Credentials on Remote Backends** — Mount skill directories and credential files into Modal and Docker containers, so remote terminal sessions have access to the same skills and secrets as local execution. ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890), [#3671](https://github.com/NousResearch/hermes-agent/pull/3671), closes [#3665](https://github.com/NousResearch/hermes-agent/issues/3665), [#3433](https://github.com/NousResearch/hermes-agent/issues/3433)) + +--- + +## 🏗️ Core Agent & Architecture + +### Provider & Model Support +- **Ordered fallback provider chain** — automatic failover across multiple configured providers ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813)) +- **Fix api_mode on provider switch** — switching providers via `hermes model` now correctly clears stale `api_mode` instead of hardcoding `chat_completions`, fixing 404s for providers with Anthropic-compatible endpoints ([#3726](https://github.com/NousResearch/hermes-agent/pull/3726), [#3857](https://github.com/NousResearch/hermes-agent/pull/3857), closes [#3685](https://github.com/NousResearch/hermes-agent/issues/3685)) +- **Stop silent OpenRouter fallback** — when no provider is configured, Hermes now raises a clear error instead of silently routing to OpenRouter ([#3807](https://github.com/NousResearch/hermes-agent/pull/3807), [#3862](https://github.com/NousResearch/hermes-agent/pull/3862)) +- **Gemini 3.1 preview models** — added to OpenRouter and Nous Portal catalogs ([#3803](https://github.com/NousResearch/hermes-agent/pull/3803), closes [#3753](https://github.com/NousResearch/hermes-agent/issues/3753)) +- **Gemini direct API context length** — full context length resolution for direct Google AI endpoints ([#3876](https://github.com/NousResearch/hermes-agent/pull/3876)) +- **gpt-5.4-mini** added to Codex fallback catalog ([#3855](https://github.com/NousResearch/hermes-agent/pull/3855)) +- **Curated model lists preferred** over live API probe when the probe returns fewer models ([#3856](https://github.com/NousResearch/hermes-agent/pull/3856), [#3867](https://github.com/NousResearch/hermes-agent/pull/3867)) +- **User-friendly 429 rate limit messages** with Retry-After countdown ([#3809](https://github.com/NousResearch/hermes-agent/pull/3809)) +- **Auxiliary client placeholder key** for local servers without auth requirements ([#3842](https://github.com/NousResearch/hermes-agent/pull/3842)) +- **INFO-level logging** for auxiliary provider resolution ([#3866](https://github.com/NousResearch/hermes-agent/pull/3866)) + +### Agent Loop & Conversation +- **Subagent status reporting** — reports `completed` status when summary exists instead of generic failure ([#3829](https://github.com/NousResearch/hermes-agent/pull/3829)) +- **Session log file updated during compression** — prevents stale file references after context compression ([#3835](https://github.com/NousResearch/hermes-agent/pull/3835)) +- **Omit empty tools param** — sends no `tools` parameter when empty instead of `None`, fixing compatibility with strict providers ([#3820](https://github.com/NousResearch/hermes-agent/pull/3820)) + +### Profiles & Multi-Instance +- **Profiles system** — `hermes profile create/list/switch/delete/export/import/rename`. Each profile gets isolated HERMES_HOME, gateway service, CLI wrapper. Token locks prevent credential collisions. Tab completion for profile names. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681)) +- **Profile-aware display paths** — all user-facing `~/.hermes` paths replaced with `display_hermes_home()` to show the correct profile directory ([#3623](https://github.com/NousResearch/hermes-agent/pull/3623)) +- **Lazy display_hermes_home imports** — prevents `ImportError` during `hermes update` when modules cache stale bytecode ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776)) +- **HERMES_HOME for protected paths** — `.env` write-deny path now respects HERMES_HOME instead of hardcoded `~/.hermes` ([#3840](https://github.com/NousResearch/hermes-agent/pull/3840)) + +--- + +## 📱 Messaging Platforms (Gateway) + +### New Platforms +- **Feishu/Lark** — Full adapter with event subscriptions, message cards, group chat, image/file attachments, interactive card callbacks ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817)) +- **WeCom (Enterprise WeChat)** — Text/image/voice messages, group chats, callback verification ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847)) + +### Telegram +- **Webhook mode** — run as webhook endpoint instead of polling for production deployments ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880)) +- **Group mention gating & regex triggers** — configurable bot response behavior in groups: always, @mention-only, or regex-matched ([#3870](https://github.com/NousResearch/hermes-agent/pull/3870)) +- **Gracefully handle deleted reply targets** — no more crashes when the message being replied to was deleted ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858), closes [#3229](https://github.com/NousResearch/hermes-agent/issues/3229)) + +### Discord +- **Message processing reactions** — adds a reaction emoji while processing and removes it when done, giving visual feedback in channels ([#3871](https://github.com/NousResearch/hermes-agent/pull/3871)) +- **DISCORD_IGNORE_NO_MENTION** — skip messages that @mention other users/bots but not Hermes ([#3640](https://github.com/NousResearch/hermes-agent/pull/3640)) +- **Clean up deferred "thinking..."** — properly removes the "thinking..." indicator after slash commands complete ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674), closes [#3595](https://github.com/NousResearch/hermes-agent/issues/3595)) + +### Slack +- **Multi-workspace OAuth** — connect to multiple Slack workspaces from a single gateway via OAuth token file ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903)) + +### WhatsApp +- **Persistent aiohttp session** — reuse HTTP sessions across requests instead of creating new ones per message ([#3818](https://github.com/NousResearch/hermes-agent/pull/3818)) +- **LID↔phone alias resolution** — correctly match Linked ID and phone number formats in allowlists ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830)) +- **Skip reply prefix in bot mode** — cleaner message formatting when running as a WhatsApp bot ([#3931](https://github.com/NousResearch/hermes-agent/pull/3931)) + +### Matrix +- **Native voice messages via MSC3245** — send voice messages as proper Matrix voice events instead of file attachments ([#3877](https://github.com/NousResearch/hermes-agent/pull/3877)) + +### Mattermost +- **Configurable mention behavior** — respond to messages without requiring @mention ([#3664](https://github.com/NousResearch/hermes-agent/pull/3664)) + +### Signal +- **URL-encode phone numbers** and correct attachment RPC parameter — fixes delivery failures with certain phone number formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) — @kshitijk4poor + +### Email +- **Close SMTP/IMAP connections on failure** — prevents connection leaks during error scenarios ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804)) + +### Gateway Core +- **Atomic config writes** — use atomic file writes for config.yaml to prevent data loss during crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800)) +- **Home channel env overrides** — apply environment variable overrides for home channels consistently ([#3796](https://github.com/NousResearch/hermes-agent/pull/3796), [#3808](https://github.com/NousResearch/hermes-agent/pull/3808)) +- **Replace print() with logger** — BasePlatformAdapter now uses proper logging instead of print statements ([#3669](https://github.com/NousResearch/hermes-agent/pull/3669)) +- **Cron delivery labels** — resolve human-friendly delivery labels via channel directory ([#3860](https://github.com/NousResearch/hermes-agent/pull/3860), closes [#1945](https://github.com/NousResearch/hermes-agent/issues/1945)) +- **Cron [SILENT] tightening** — prevent agents from prefixing reports with [SILENT] to suppress delivery ([#3901](https://github.com/NousResearch/hermes-agent/pull/3901)) +- **Background task media delivery** and vision download timeout fixes ([#3919](https://github.com/NousResearch/hermes-agent/pull/3919)) +- **Boot-md hook** — example built-in hook to run a BOOT.md file on gateway startup ([#3733](https://github.com/NousResearch/hermes-agent/pull/3733)) + +--- + +## 🖥️ CLI & User Experience + +### Interactive CLI +- **Configurable tool preview length** — show full file paths by default instead of truncating at 40 chars ([#3841](https://github.com/NousResearch/hermes-agent/pull/3841)) +- **Tool token context display** — `hermes tools` checklist now shows estimated token cost per toolset ([#3805](https://github.com/NousResearch/hermes-agent/pull/3805)) +- **/bg spinner TUI fix** — route background task spinner through the TUI widget to prevent status bar collision ([#3643](https://github.com/NousResearch/hermes-agent/pull/3643)) +- **Prevent status bar wrapping** into duplicate rows ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) — @kshitijk4poor +- **Handle closed stdout ValueError** in safe print paths — fixes crashes when stdout is closed during gateway thread shutdown ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843), closes [#3534](https://github.com/NousResearch/hermes-agent/issues/3534)) +- **Remove input() from /tools disable** — eliminates freeze in terminal when disabling tools ([#3918](https://github.com/NousResearch/hermes-agent/pull/3918)) +- **TTY guard for interactive CLI commands** — prevent CPU spin when launched without a terminal ([#3933](https://github.com/NousResearch/hermes-agent/pull/3933)) +- **Argparse entrypoint** — use argparse in the top-level launcher for cleaner error handling ([#3874](https://github.com/NousResearch/hermes-agent/pull/3874)) +- **Lazy-initialized tools show yellow** in banner instead of red, reducing false alarm about "missing" tools ([#3822](https://github.com/NousResearch/hermes-agent/pull/3822)) +- **Honcho tools shown in banner** when configured ([#3810](https://github.com/NousResearch/hermes-agent/pull/3810)) + +### Setup & Configuration +- **Auto-install matrix-nio** during `hermes setup` when Matrix is selected ([#3802](https://github.com/NousResearch/hermes-agent/pull/3802), [#3873](https://github.com/NousResearch/hermes-agent/pull/3873)) +- **Session export stdout support** — export sessions to stdout with `-` for piping ([#3641](https://github.com/NousResearch/hermes-agent/pull/3641), closes [#3609](https://github.com/NousResearch/hermes-agent/issues/3609)) +- **Configurable approval timeouts** — set how long dangerous command approval prompts wait before auto-denying ([#3886](https://github.com/NousResearch/hermes-agent/pull/3886), closes [#3765](https://github.com/NousResearch/hermes-agent/issues/3765)) +- **Clear __pycache__ during update** — prevents stale bytecode ImportError after `hermes update` ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819)) + +--- + +## 🔧 Tool System + +### MCP +- **MCP Server Mode** — `hermes mcp serve` exposes conversations, sessions, and attachments to MCP clients via stdio or Streamable HTTP ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795)) +- **Dynamic tool discovery** — respond to `notifications/tools/list_changed` events to pick up new tools from MCP servers without reconnecting ([#3812](https://github.com/NousResearch/hermes-agent/pull/3812)) +- **Non-deprecated HTTP transport** — switched from `sse_client` to `streamable_http_client` ([#3646](https://github.com/NousResearch/hermes-agent/pull/3646)) + +### Web Tools +- **Exa search backend** — alternative to Firecrawl and DuckDuckGo for web search and extraction ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648)) + +### Browser +- **Guard against None LLM responses** in browser snapshot and vision tools ([#3642](https://github.com/NousResearch/hermes-agent/pull/3642)) + +### Terminal & Remote Backends +- **Mount skill directories** into Modal and Docker containers ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890)) +- **Mount credential files** into remote backends with mtime+size caching ([#3671](https://github.com/NousResearch/hermes-agent/pull/3671)) +- **Preserve partial output** when commands time out instead of losing everything ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868)) +- **Stop marking persisted env vars as missing** on remote backends ([#3650](https://github.com/NousResearch/hermes-agent/pull/3650)) + +### Audio +- **.aac format support** in transcription tool ([#3865](https://github.com/NousResearch/hermes-agent/pull/3865), closes [#1963](https://github.com/NousResearch/hermes-agent/issues/1963)) +- **Audio download retry** — retry logic for `cache_audio_from_url` matching the existing image download pattern ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) — @binhnt92 + +### Vision +- **Reject non-image files** and enforce website-only policy for vision analysis ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845)) + +### Tool Schema +- **Ensure name field** always present in tool definitions, fixing `KeyError: 'name'` crashes ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811), closes [#3729](https://github.com/NousResearch/hermes-agent/issues/3729)) + +### ACP (Editor Integration) +- **Complete session management surface** for VS Code/Zed/JetBrains clients — proper task lifecycle, cancel support, session persistence ([#3675](https://github.com/NousResearch/hermes-agent/pull/3675)) + +--- + +## 🧩 Skills & Plugins + +### Skills System +- **External skill directories** — configure additional skill directories via `skills.external_dirs` in config.yaml ([#3678](https://github.com/NousResearch/hermes-agent/pull/3678)) +- **Category path traversal blocked** — prevents `../` attacks in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844)) +- **parallel-cli moved to optional-skills** — reduces default skill footprint ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)) — @kshitijk4poor + +### New Skills +- **memento-flashcards** — spaced repetition flashcard system ([#3827](https://github.com/NousResearch/hermes-agent/pull/3827)) +- **songwriting-and-ai-music** — songwriting craft and AI music generation prompts ([#3834](https://github.com/NousResearch/hermes-agent/pull/3834)) +- **SiYuan Note** — integration with SiYuan note-taking app ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742)) +- **Scrapling** — web scraping skill using Scrapling library ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742)) +- **one-three-one-rule** — communication framework skill ([#3797](https://github.com/NousResearch/hermes-agent/pull/3797)) + +### Plugin System +- **Plugin enable/disable commands** — `hermes plugins enable/disable ` for managing plugin state without removing them ([#3747](https://github.com/NousResearch/hermes-agent/pull/3747)) +- **Plugin message injection** — plugins can now inject messages into the conversation stream on behalf of the user via `ctx.inject_message()` ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) — @winglian +- **Honcho self-hosted support** — allow local Honcho instances without requiring an API key ([#3644](https://github.com/NousResearch/hermes-agent/pull/3644)) + +--- + +## 🔒 Security & Reliability + +### Security Hardening +- **Hardened dangerous command detection** — expanded pattern matching for risky shell commands and added file tool path guards for sensitive locations (`/etc/`, `/boot/`, docker.sock) ([#3872](https://github.com/NousResearch/hermes-agent/pull/3872)) +- **Sensitive path write checks** in approval system — catch writes to system config files through file tools, not just terminal ([#3859](https://github.com/NousResearch/hermes-agent/pull/3859)) +- **Secret redaction expansion** — now covers ElevenLabs, Tavily, and Exa API keys ([#3920](https://github.com/NousResearch/hermes-agent/pull/3920)) +- **Vision file rejection** — reject non-image files passed to vision analysis to prevent information disclosure ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845)) +- **Category path traversal blocking** — prevent directory traversal in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844)) + +### Reliability +- **Atomic config.yaml writes** — prevent data loss during gateway crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800)) +- **Clear __pycache__ on update** — prevent stale bytecode from causing ImportError after updates ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819)) +- **Lazy imports for update safety** — prevent ImportError chains during `hermes update` when modules reference new functions ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776)) +- **Restore terminalbench2 from patch corruption** — recovered file damaged by patch tool's secret redaction ([#3801](https://github.com/NousResearch/hermes-agent/pull/3801)) +- **Terminal timeout preserves partial output** — no more lost command output on timeout ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868)) + +--- + +## 🐛 Notable Bug Fixes + +- **OpenClaw migration model config overwrite** — migration no longer overwrites model config dict with a string ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) — @0xbyt4 +- **OpenClaw migration expanded** — covers full data footprint including sessions, cron, memory ([#3869](https://github.com/NousResearch/hermes-agent/pull/3869)) +- **Telegram deleted reply targets** — gracefully handle replies to deleted messages instead of crashing ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858)) +- **Discord "thinking..." persistence** — properly cleans up deferred response indicators ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674)) +- **WhatsApp LID↔phone aliases** — fixes allowlist matching failures with Linked ID format ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830)) +- **Signal URL-encoded phone numbers** — fixes delivery failures with certain formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) +- **Email connection leaks** — properly close SMTP/IMAP connections on error ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804)) +- **_safe_print ValueError** — no more gateway thread crashes on closed stdout ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843)) +- **Tool schema KeyError 'name'** — ensure name field always present in tool definitions ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811)) +- **api_mode stale on provider switch** — correctly clear when switching providers via `hermes model` ([#3857](https://github.com/NousResearch/hermes-agent/pull/3857)) + +--- + +## 🧪 Testing + +- Resolved 10+ CI failures across hooks, tiktoken, plugins, and skill tests ([#3848](https://github.com/NousResearch/hermes-agent/pull/3848), [#3721](https://github.com/NousResearch/hermes-agent/pull/3721), [#3936](https://github.com/NousResearch/hermes-agent/pull/3936)) + +--- + +## 📚 Documentation + +- **Comprehensive OpenClaw migration guide** — step-by-step guide for migrating from OpenClaw/Claw3D to Hermes Agent ([#3864](https://github.com/NousResearch/hermes-agent/pull/3864), [#3900](https://github.com/NousResearch/hermes-agent/pull/3900)) +- **Credential file passthrough docs** — document how to forward credential files and env vars to remote backends ([#3677](https://github.com/NousResearch/hermes-agent/pull/3677)) +- **DuckDuckGo requirements clarified** — note runtime dependency on duckduckgo-search package ([#3680](https://github.com/NousResearch/hermes-agent/pull/3680)) +- **Skills catalog updated** — added red-teaming category and optional skills listing ([#3745](https://github.com/NousResearch/hermes-agent/pull/3745)) +- **Feishu docs MDX fix** — escape angle-bracket URLs that break Docusaurus build ([#3902](https://github.com/NousResearch/hermes-agent/pull/3902)) + +--- + +## 👥 Contributors + +### Core +- **@teknium1** — 90 PRs across all subsystems + +### Community Contributors +- **@kshitijk4poor** — 3 PRs: Signal phone number fix ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)), parallel-cli to optional-skills ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)), status bar wrapping fix ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) +- **@winglian** — 1 PR: Plugin message injection interface ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) +- **@binhnt92** — 1 PR: Audio download retry logic ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) +- **@0xbyt4** — 1 PR: OpenClaw migration model config fix ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) + +### Issues Resolved from Community +@Material-Scientist ([#850](https://github.com/NousResearch/hermes-agent/issues/850)), @hanxu98121 ([#1734](https://github.com/NousResearch/hermes-agent/issues/1734)), @penwyp ([#1788](https://github.com/NousResearch/hermes-agent/issues/1788)), @dan-and ([#1945](https://github.com/NousResearch/hermes-agent/issues/1945)), @AdrianScott ([#1963](https://github.com/NousResearch/hermes-agent/issues/1963)), @clawdbot47 ([#3229](https://github.com/NousResearch/hermes-agent/issues/3229)), @alanfwilliams ([#3404](https://github.com/NousResearch/hermes-agent/issues/3404)), @kentimsit ([#3433](https://github.com/NousResearch/hermes-agent/issues/3433)), @hayka-pacha ([#3534](https://github.com/NousResearch/hermes-agent/issues/3534)), @primmer ([#3595](https://github.com/NousResearch/hermes-agent/issues/3595)), @dagelf ([#3609](https://github.com/NousResearch/hermes-agent/issues/3609)), @HenkDz ([#3685](https://github.com/NousResearch/hermes-agent/issues/3685)), @tmdgusya ([#3729](https://github.com/NousResearch/hermes-agent/issues/3729)), @TypQxQ ([#3753](https://github.com/NousResearch/hermes-agent/issues/3753)), @acsezen ([#3765](https://github.com/NousResearch/hermes-agent/issues/3765)) + +--- + +**Full Changelog**: [v2026.3.28...v2026.3.30](https://github.com/NousResearch/hermes-agent/compare/v2026.3.28...v2026.3.30) diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 797c7e8d64..5f4b1b9cf0 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -11,5 +11,5 @@ Provides subcommands for: - hermes cron - Manage cron jobs """ -__version__ = "0.5.0" -__release_date__ = "2026.3.28" +__version__ = "0.6.0" +__release_date__ = "2026.3.30" diff --git a/pyproject.toml b/pyproject.toml index 38974e3287..c3154d1aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-agent" -version = "0.5.0" +version = "0.6.0" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" requires-python = ">=3.11" From 37825189dddcff5686ff5f3dab4025c7313e72a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:37:19 -0700 Subject: [PATCH 28/52] fix(skills): validate hub bundle paths before install (#3986) Co-authored-by: Gutslabs --- hermes_cli/skills_hub.py | 19 ++++++- tests/tools/test_skills_hub.py | 79 +++++++++++++++++++++++++++ tools/skills_hub.py | 98 ++++++++++++++++++++++++++++------ 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 359e8b912a..370b69ab0c 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -354,7 +354,14 @@ def do_install(identifier: str, category: str = "", force: bool = False, extra_metadata.update(getattr(bundle, "metadata", {}) or {}) # Quarantine the bundle - q_path = quarantine_bundle(bundle) + try: + q_path = quarantine_bundle(bundle) + except ValueError as exc: + c.print(f"[bold red]Installation blocked:[/] {exc}\n") + from tools.skills_hub import append_audit_log + append_audit_log("BLOCKED", bundle.name, bundle.source, + bundle.trust_level, "invalid_path", str(exc)) + return c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]") # Scan @@ -414,7 +421,15 @@ def do_install(identifier: str, category: str = "", force: bool = False, return # Install - install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result) + try: + install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result) + except ValueError as exc: + c.print(f"[bold red]Installation blocked:[/] {exc}\n") + shutil.rmtree(q_path, ignore_errors=True) + from tools.skills_hub import append_audit_log + append_audit_log("BLOCKED", bundle.name, bundle.source, + bundle.trust_level, "invalid_path", str(exc)) + return from tools.skills_hub import SKILLS_DIR c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}") c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n") diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index a55a91e001..58e0354697 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -5,6 +5,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock import httpx +import pytest from tools.skills_hub import ( GitHubAuth, @@ -648,6 +649,29 @@ class TestWellKnownSkillSource: assert bundle.files["SKILL.md"] == "# Code Review\n" assert bundle.files["references/checklist.md"] == "- [ ] security\n" + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_fetch_rejects_unsafe_file_paths_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache): + def fake_get(url, *args, **kwargs): + if url.endswith("/index.json"): + return MagicMock(status_code=200, json=lambda: { + "skills": [{ + "name": "code-review", + "description": "Review code", + "files": ["SKILL.md", "../../../escape.txt"], + }] + }) + if url.endswith("/code-review/SKILL.md"): + return MagicMock(status_code=200, text="# Code Review\n") + raise AssertionError(url) + + mock_get.side_effect = fake_get + + bundle = self._source().fetch("well-known:https://example.com/.well-known/skills/code-review") + + assert bundle is None + class TestCheckForSkillUpdates: def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path): @@ -1143,6 +1167,61 @@ class TestQuarantineBundleBinaryAssets: assert (q_path / "SKILL.md").read_text(encoding="utf-8").startswith("---") assert (q_path / "assets" / "neutts-cli" / "samples" / "jo.wav").read_bytes() == b"RIFF\x00\x01fakewav" + def test_quarantine_bundle_rejects_traversal_file_paths(self, tmp_path): + import tools.skills_hub as hub + + hub_dir = tmp_path / "skills" / ".hub" + with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \ + patch.object(hub, "HUB_DIR", hub_dir), \ + patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \ + patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \ + patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \ + patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \ + patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"): + bundle = SkillBundle( + name="demo", + files={ + "SKILL.md": "---\nname: demo\n---\n", + "../../../escape.txt": "owned", + }, + source="well-known", + identifier="well-known:https://example.com/.well-known/skills/demo", + trust_level="community", + ) + + with pytest.raises(ValueError, match="Unsafe bundle file path"): + quarantine_bundle(bundle) + + assert not (tmp_path / "skills" / "escape.txt").exists() + + def test_quarantine_bundle_rejects_absolute_file_paths(self, tmp_path): + import tools.skills_hub as hub + + hub_dir = tmp_path / "skills" / ".hub" + absolute_target = tmp_path / "outside.txt" + with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \ + patch.object(hub, "HUB_DIR", hub_dir), \ + patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \ + patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \ + patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \ + patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \ + patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"): + bundle = SkillBundle( + name="demo", + files={ + "SKILL.md": "---\nname: demo\n---\n", + str(absolute_target): "owned", + }, + source="well-known", + identifier="well-known:https://example.com/.well-known/skills/demo", + trust_level="community", + ) + + with pytest.raises(ValueError, match="Unsafe bundle file path"): + quarantine_bundle(bundle) + + assert not absolute_target.exists() + # --------------------------------------------------------------------------- # GitHubSource._download_directory — tree API + fallback (#2940) diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 86f8e47d19..a824c3e3bd 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -24,7 +24,7 @@ import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import urlparse, urlunparse @@ -85,6 +85,43 @@ class SkillBundle: metadata: Dict[str, Any] = field(default_factory=dict) +def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str: + """Normalize and validate bundle-controlled paths before touching disk.""" + if not isinstance(path_value, str): + raise ValueError(f"Unsafe {field_name}: expected a string") + + raw = path_value.strip() + if not raw: + raise ValueError(f"Unsafe {field_name}: empty path") + + normalized = raw.replace("\\", "/") + path = PurePosixPath(normalized) + parts = [part for part in path.parts if part not in ("", ".")] + + if normalized.startswith("/") or path.is_absolute(): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if not parts or any(part == ".." for part in parts): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if re.fullmatch(r"[A-Za-z]:", parts[0]): + raise ValueError(f"Unsafe {field_name}: {path_value}") + if not allow_nested and len(parts) != 1: + raise ValueError(f"Unsafe {field_name}: {path_value}") + + return "/".join(parts) + + +def _validate_skill_name(name: str) -> str: + return _normalize_bundle_path(name, field_name="skill name", allow_nested=False) + + +def _validate_category_name(category: str) -> str: + return _normalize_bundle_path(category, field_name="category", allow_nested=False) + + +def _validate_bundle_rel_path(rel_path: str) -> str: + return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True) + + # --------------------------------------------------------------------------- # GitHub Authentication # --------------------------------------------------------------------------- @@ -701,6 +738,12 @@ class WellKnownSkillSource(SkillSource): if not parsed: return None + try: + skill_name = _validate_skill_name(parsed["skill_name"]) + except ValueError: + logger.warning("Well-known skill identifier contained unsafe skill name: %s", identifier) + return None + entry = self._index_entry(parsed["index_url"], parsed["skill_name"]) if not entry: return None @@ -713,19 +756,28 @@ class WellKnownSkillSource(SkillSource): for rel_path in files: if not isinstance(rel_path, str) or not rel_path: continue - text = self._fetch_text(f"{parsed['skill_url']}/{rel_path}") + try: + safe_rel_path = _validate_bundle_rel_path(rel_path) + except ValueError: + logger.warning( + "Well-known skill %s advertised unsafe file path: %r", + identifier, + rel_path, + ) + return None + text = self._fetch_text(f"{parsed['skill_url']}/{safe_rel_path}") if text is None: return None - downloaded[rel_path] = text + downloaded[safe_rel_path] = text if "SKILL.md" not in downloaded: return None return SkillBundle( - name=parsed["skill_name"], + name=skill_name, files=downloaded, source="well-known", - identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]), + identifier=self._wrap_identifier(parsed["base_url"], skill_name), trust_level="community", metadata={ "index_url": parsed["index_url"], @@ -1752,9 +1804,10 @@ class ClawHubSource(SkillSource): for info in zf.infolist(): if info.is_dir(): continue - # Sanitize path — strip leading slashes and .. - name = info.filename.lstrip("/") - if ".." in name or name.startswith("/"): + try: + name = _validate_bundle_rel_path(info.filename) + except ValueError: + logger.debug("Skipping unsafe ZIP member path: %s", info.filename) continue # Only extract text-sized files (skip large binaries) if info.file_size > 500_000: @@ -2423,13 +2476,19 @@ def ensure_hub_dirs() -> None: def quarantine_bundle(bundle: SkillBundle) -> Path: """Write a skill bundle to the quarantine directory for scanning.""" ensure_hub_dirs() - dest = QUARANTINE_DIR / bundle.name + skill_name = _validate_skill_name(bundle.name) + validated_files: List[Tuple[str, Union[str, bytes]]] = [] + for rel_path, file_content in bundle.files.items(): + safe_rel_path = _validate_bundle_rel_path(rel_path) + validated_files.append((safe_rel_path, file_content)) + + dest = QUARANTINE_DIR / skill_name if dest.exists(): shutil.rmtree(dest) dest.mkdir(parents=True) - for rel_path, file_content in bundle.files.items(): - file_dest = dest / rel_path + for rel_path, file_content in validated_files: + file_dest = dest.joinpath(*rel_path.split("/")) file_dest.parent.mkdir(parents=True, exist_ok=True) if isinstance(file_content, bytes): file_dest.write_bytes(file_content) @@ -2447,10 +2506,17 @@ def install_from_quarantine( scan_result: ScanResult, ) -> Path: """Move a scanned skill from quarantine into the skills directory.""" - if category: - install_dir = SKILLS_DIR / category / skill_name + safe_skill_name = _validate_skill_name(skill_name) + safe_category = _validate_category_name(category) if category else "" + quarantine_resolved = quarantine_path.resolve() + quarantine_root = QUARANTINE_DIR.resolve() + if not quarantine_resolved.is_relative_to(quarantine_root): + raise ValueError(f"Unsafe quarantine path: {quarantine_path}") + + if safe_category: + install_dir = SKILLS_DIR / safe_category / safe_skill_name else: - install_dir = SKILLS_DIR / skill_name + install_dir = SKILLS_DIR / safe_skill_name if install_dir.exists(): shutil.rmtree(install_dir) @@ -2461,7 +2527,7 @@ def install_from_quarantine( # Record in lock file lock = HubLockFile() lock.record_install( - name=skill_name, + name=safe_skill_name, source=bundle.source, identifier=bundle.identifier, trust_level=bundle.trust_level, @@ -2473,7 +2539,7 @@ def install_from_quarantine( ) append_audit_log( - "INSTALL", skill_name, bundle.source, + "INSTALL", safe_skill_name, bundle.source, bundle.trust_level, scan_result.verdict, content_hash(install_dir), ) From 97d6813f513b28ce6cd7d6919c729702dfb3d5f3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:43:56 -0700 Subject: [PATCH 29/52] fix(cache): use deterministic call_id fallbacks instead of random UUIDs (#3991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the API doesn't provide a call_id for tool calls, the fallback generated a random uuid4 hex. This made every API call's input unique when replayed, preventing OpenAI's prompt cache from matching the prefix across turns. Replaced all four uuid4 fallback sites with a deterministic hash of (function_name, arguments, position_index). The same tool call now always produces the same fallback call_id, preserving cache-friendly input stability. Affected code paths: - _chat_messages_to_responses_input() — Codex input reconstruction - _normalize_codex_response() — function_call and custom_tool_call - _build_assistant_message() — assistant message construction --- run_agent.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/run_agent.py b/run_agent.py index 30453c01ce..13eba7fe7c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2907,6 +2907,19 @@ class AIAgent: }) return converted or None + @staticmethod + def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str: + """Generate a deterministic call_id from tool call content. + + Used as a fallback when the API doesn't provide a call_id. + Deterministic IDs prevent cache invalidation — random UUIDs would + make every API call's prefix unique, breaking OpenAI's prompt cache. + """ + import hashlib + seed = f"{fn_name}:{arguments}:{index}" + digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12] + return f"call_{digest}" + @staticmethod def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]: """Split a stored tool id into (call_id, response_item_id).""" @@ -3013,7 +3026,8 @@ class AIAgent: ): call_id = f"call_{embedded_response_item_id[len('fc_'):]}" else: - call_id = f"call_{uuid.uuid4().hex[:12]}" + _raw_args = str(fn.get("arguments", "{}")) + call_id = self._deterministic_call_id(fn_name, _raw_args, len(items)) call_id = call_id.strip() arguments = fn.get("arguments", "{}") @@ -3377,7 +3391,7 @@ class AIAgent: embedded_call_id, _ = self._split_responses_tool_id(raw_item_id) call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id if not isinstance(call_id, str) or not call_id.strip(): - call_id = f"call_{uuid.uuid4().hex[:12]}" + call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls)) call_id = call_id.strip() response_item_id = raw_item_id if isinstance(raw_item_id, str) else None response_item_id = self._derive_responses_function_call_id(call_id, response_item_id) @@ -3398,7 +3412,7 @@ class AIAgent: embedded_call_id, _ = self._split_responses_tool_id(raw_item_id) call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id if not isinstance(call_id, str) or not call_id.strip(): - call_id = f"call_{uuid.uuid4().hex[:12]}" + call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls)) call_id = call_id.strip() response_item_id = raw_item_id if isinstance(raw_item_id, str) else None response_item_id = self._derive_responses_function_call_id(call_id, response_item_id) @@ -4933,7 +4947,10 @@ class AIAgent: if isinstance(raw_id, str) and raw_id.strip(): call_id = raw_id.strip() else: - call_id = f"call_{uuid.uuid4().hex[:12]}" + _fn = getattr(tool_call, "function", None) + _fn_name = getattr(_fn, "name", "") if _fn else "" + _fn_args = getattr(_fn, "arguments", "{}") if _fn else "{}" + call_id = self._deterministic_call_id(_fn_name, _fn_args, len(tool_calls)) call_id = call_id.strip() response_item_id = getattr(tool_call, "response_item_id", None) From 5ceed021dcd2bb8ecac43cdf8db0c3849dd43aa2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:57:30 -0700 Subject: [PATCH 30/52] feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap Map active skills to Telegram's slash command menu so users can discover and invoke skills directly. Three changes: 1. Telegram menu now includes active skill commands alongside built-in commands, capped at 100 entries (Telegram Bot API limit). Overflow commands remain callable but hidden from the picker. Logged at startup when cap is hit. 2. New /commands [page] gateway command for paginated browsing of all commands + skills. /help now shows first 10 skill commands and points to /commands for the full list. 3. When a user types a slash command that matches a disabled or uninstalled skill, they get actionable guidance: - Disabled: 'Enable it with: hermes skills config' - Optional (not installed): 'Install with: hermes skills install official/' Built on ideas from PR #3921 by @kshitijk4poor. * chore: move 21 niche skills to optional-skills Move specialized/niche skills from built-in (skills/) to optional (optional-skills/) to reduce the default skill count. Users can install them with: hermes skills install official// Moved skills (21): - mlops: accelerate, chroma, faiss, flash-attention, hermes-atropos-environments, huggingface-tokenizers, instructor, lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning, qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan - research: domain-intel, duckduckgo-search - devops: inference-sh cli Built-in skills: 96 → 75 Optional skills: 22 → 43 * fix: only include repo built-in skills in Telegram menu, not user-installed User-installed skills (from hub or manually added) stay accessible via /skills and by typing the command directly, but don't get registered in the Telegram slash command picker. Only skills whose SKILL.md is under the repo's skills/ directory are included in the menu. This keeps the Telegram menu focused on the curated built-in set while user-installed skills remain discoverable through /skills and /commands. --- gateway/platforms/telegram.py | 10 +- gateway/run.py | 114 +++++++++++++++++- hermes_cli/commands.py | 43 +++++++ .../devops}/cli/SKILL.md | 0 .../devops}/cli/references/app-discovery.md | 0 .../devops}/cli/references/authentication.md | 0 .../devops}/cli/references/cli-reference.md | 0 .../devops}/cli/references/running-apps.md | 0 .../mlops}/accelerate/SKILL.md | 0 .../accelerate/references/custom-plugins.md | 0 .../references/megatron-integration.md | 0 .../accelerate/references/performance.md | 0 .../mlops}/chroma/SKILL.md | 0 .../mlops}/chroma/references/integration.md | 0 .../mlops}/faiss/SKILL.md | 0 .../mlops}/faiss/references/index_types.md | 0 .../mlops}/flash-attention/SKILL.md | 0 .../flash-attention/references/benchmarks.md | 0 .../references/transformers-integration.md | 0 .../hermes-atropos-environments/SKILL.md | 0 .../references/agentresult-fields.md | 0 .../references/atropos-base-env.md | 0 .../references/usage-patterns.md | 0 .../mlops}/huggingface-tokenizers/SKILL.md | 0 .../references/algorithms.md | 0 .../references/integration.md | 0 .../references/pipeline.md | 0 .../references/training.md | 0 .../mlops}/instructor/SKILL.md | 0 .../mlops}/instructor/references/examples.md | 0 .../mlops}/instructor/references/providers.md | 0 .../instructor/references/validation.md | 0 .../mlops}/lambda-labs/SKILL.md | 0 .../lambda-labs/references/advanced-usage.md | 0 .../lambda-labs/references/troubleshooting.md | 0 .../mlops}/llava/SKILL.md | 0 .../mlops}/llava/references/training.md | 0 .../mlops}/nemo-curator/SKILL.md | 0 .../nemo-curator/references/deduplication.md | 0 .../nemo-curator/references/filtering.md | 0 .../mlops}/pinecone/SKILL.md | 0 .../mlops}/pinecone/references/deployment.md | 0 .../mlops}/pytorch-lightning/SKILL.md | 0 .../pytorch-lightning/references/callbacks.md | 0 .../references/distributed.md | 0 .../references/hyperparameter-tuning.md | 0 .../mlops}/qdrant/SKILL.md | 0 .../qdrant/references/advanced-usage.md | 0 .../qdrant/references/troubleshooting.md | 0 .../mlops}/saelens/SKILL.md | 0 .../mlops}/saelens/references/README.md | 0 .../mlops}/saelens/references/api.md | 0 .../mlops}/saelens/references/tutorials.md | 0 .../mlops}/simpo/SKILL.md | 0 .../mlops}/simpo/references/datasets.md | 0 .../simpo/references/hyperparameters.md | 0 .../mlops}/simpo/references/loss-functions.md | 0 .../mlops}/slime/SKILL.md | 0 .../mlops}/slime/references/api-reference.md | 0 .../slime/references/troubleshooting.md | 0 .../mlops}/tensorrt-llm/SKILL.md | 0 .../tensorrt-llm/references/multi-gpu.md | 0 .../tensorrt-llm/references/optimization.md | 0 .../mlops}/tensorrt-llm/references/serving.md | 0 .../mlops}/torchtitan/SKILL.md | 0 .../torchtitan/references/checkpoint.md | 0 .../torchtitan/references/custom-models.md | 0 .../mlops}/torchtitan/references/float8.md | 0 .../mlops}/torchtitan/references/fsdp.md | 0 .../research/domain-intel/SKILL.md | 0 .../domain-intel/scripts/domain_intel.py | 0 .../research/duckduckgo-search/SKILL.md | 0 .../duckduckgo-search/scripts/duckduckgo.sh | 0 73 files changed, 163 insertions(+), 4 deletions(-) rename {skills/inference-sh => optional-skills/devops}/cli/SKILL.md (100%) rename {skills/inference-sh => optional-skills/devops}/cli/references/app-discovery.md (100%) rename {skills/inference-sh => optional-skills/devops}/cli/references/authentication.md (100%) rename {skills/inference-sh => optional-skills/devops}/cli/references/cli-reference.md (100%) rename {skills/inference-sh => optional-skills/devops}/cli/references/running-apps.md (100%) rename {skills/mlops/training => optional-skills/mlops}/accelerate/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/accelerate/references/custom-plugins.md (100%) rename {skills/mlops/training => optional-skills/mlops}/accelerate/references/megatron-integration.md (100%) rename {skills/mlops/training => optional-skills/mlops}/accelerate/references/performance.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/chroma/SKILL.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/chroma/references/integration.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/faiss/SKILL.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/faiss/references/index_types.md (100%) rename {skills/mlops/training => optional-skills/mlops}/flash-attention/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/flash-attention/references/benchmarks.md (100%) rename {skills/mlops/training => optional-skills/mlops}/flash-attention/references/transformers-integration.md (100%) rename {skills/mlops/training => optional-skills/mlops}/hermes-atropos-environments/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/hermes-atropos-environments/references/agentresult-fields.md (100%) rename {skills/mlops/training => optional-skills/mlops}/hermes-atropos-environments/references/atropos-base-env.md (100%) rename {skills/mlops/training => optional-skills/mlops}/hermes-atropos-environments/references/usage-patterns.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/huggingface-tokenizers/SKILL.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/huggingface-tokenizers/references/algorithms.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/huggingface-tokenizers/references/integration.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/huggingface-tokenizers/references/pipeline.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/huggingface-tokenizers/references/training.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/instructor/SKILL.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/instructor/references/examples.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/instructor/references/providers.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/instructor/references/validation.md (100%) rename {skills/mlops/cloud => optional-skills/mlops}/lambda-labs/SKILL.md (100%) rename {skills/mlops/cloud => optional-skills/mlops}/lambda-labs/references/advanced-usage.md (100%) rename {skills/mlops/cloud => optional-skills/mlops}/lambda-labs/references/troubleshooting.md (100%) rename {skills/mlops/models => optional-skills/mlops}/llava/SKILL.md (100%) rename {skills/mlops/models => optional-skills/mlops}/llava/references/training.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/nemo-curator/SKILL.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/nemo-curator/references/deduplication.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/nemo-curator/references/filtering.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/pinecone/SKILL.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/pinecone/references/deployment.md (100%) rename {skills/mlops/training => optional-skills/mlops}/pytorch-lightning/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/pytorch-lightning/references/callbacks.md (100%) rename {skills/mlops/training => optional-skills/mlops}/pytorch-lightning/references/distributed.md (100%) rename {skills/mlops/training => optional-skills/mlops}/pytorch-lightning/references/hyperparameter-tuning.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/qdrant/SKILL.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/qdrant/references/advanced-usage.md (100%) rename {skills/mlops/vector-databases => optional-skills/mlops}/qdrant/references/troubleshooting.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/saelens/SKILL.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/saelens/references/README.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/saelens/references/api.md (100%) rename {skills/mlops/evaluation => optional-skills/mlops}/saelens/references/tutorials.md (100%) rename {skills/mlops/training => optional-skills/mlops}/simpo/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/simpo/references/datasets.md (100%) rename {skills/mlops/training => optional-skills/mlops}/simpo/references/hyperparameters.md (100%) rename {skills/mlops/training => optional-skills/mlops}/simpo/references/loss-functions.md (100%) rename {skills/mlops/training => optional-skills/mlops}/slime/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/slime/references/api-reference.md (100%) rename {skills/mlops/training => optional-skills/mlops}/slime/references/troubleshooting.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/tensorrt-llm/SKILL.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/tensorrt-llm/references/multi-gpu.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/tensorrt-llm/references/optimization.md (100%) rename {skills/mlops/inference => optional-skills/mlops}/tensorrt-llm/references/serving.md (100%) rename {skills/mlops/training => optional-skills/mlops}/torchtitan/SKILL.md (100%) rename {skills/mlops/training => optional-skills/mlops}/torchtitan/references/checkpoint.md (100%) rename {skills/mlops/training => optional-skills/mlops}/torchtitan/references/custom-models.md (100%) rename {skills/mlops/training => optional-skills/mlops}/torchtitan/references/float8.md (100%) rename {skills/mlops/training => optional-skills/mlops}/torchtitan/references/fsdp.md (100%) rename {skills => optional-skills}/research/domain-intel/SKILL.md (100%) rename {skills => optional-skills}/research/domain-intel/scripts/domain_intel.py (100%) rename {skills => optional-skills}/research/duckduckgo-search/SKILL.md (100%) rename {skills => optional-skills}/research/duckduckgo-search/scripts/duckduckgo.sh (100%) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index e17d104a68..91223d7b71 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -622,10 +622,16 @@ class TelegramAdapter(BasePlatformAdapter): # gateway command there automatically adds it to the Telegram menu. try: from telegram import BotCommand - from hermes_cli.commands import telegram_bot_commands + from hermes_cli.commands import telegram_menu_commands + menu_commands, hidden_count = telegram_menu_commands(max_commands=100) await self._bot.set_my_commands([ - BotCommand(name, desc) for name, desc in telegram_bot_commands() + BotCommand(name, desc) for name, desc in menu_commands ]) + if hidden_count: + logger.info( + "[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.", + self.name, len(menu_commands), hidden_count, + ) except Exception as e: logger.warning( "[%s] Could not register Telegram command menu: %s", diff --git a/gateway/run.py b/gateway/run.py index 3b51930421..2bd623b62a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -301,6 +301,50 @@ def _resolve_runtime_agent_kwargs() -> dict: } +def _check_unavailable_skill(command_name: str) -> str | None: + """Check if a command matches a known-but-inactive skill. + + Returns a helpful message if the skill exists but is disabled or only + available as an optional install. Returns None if no match found. + """ + # Normalize: command uses hyphens, skill names may use hyphens or underscores + normalized = command_name.lower().replace("_", "-") + try: + from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names + disabled = _get_disabled_skill_names() + + # Check disabled built-in skills + for skill_md in SKILLS_DIR.rglob("SKILL.md"): + if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): + continue + name = skill_md.parent.name.lower().replace("_", "-") + if name == normalized and name in disabled: + return ( + f"The **{command_name}** skill is installed but disabled.\n" + f"Enable it with: `hermes skills config`" + ) + + # Check optional skills (shipped with repo but not installed) + from hermes_constants import get_hermes_home + repo_root = Path(__file__).resolve().parent.parent + optional_dir = repo_root / "optional-skills" + if optional_dir.exists(): + for skill_md in optional_dir.rglob("SKILL.md"): + name = skill_md.parent.name.lower().replace("_", "-") + if name == normalized: + # Build install path: official// + rel = skill_md.parent.relative_to(optional_dir) + parts = list(rel.parts) + install_path = f"official/{'/'.join(parts)}" + return ( + f"The **{command_name}** skill is available but not installed.\n" + f"Install it with: `hermes skills install {install_path}`" + ) + except Exception: + pass + return None + + def _platform_config_key(platform: "Platform") -> str: """Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value).""" return "cli" if platform == Platform.LOCAL else platform.value @@ -1817,6 +1861,9 @@ class GatewayRunner: if canonical == "help": return await self._handle_help_command(event) + + if canonical == "commands": + return await self._handle_commands_command(event) if canonical == "status": return await self._handle_status_command(event) @@ -1974,6 +2021,12 @@ class GatewayRunner: if msg: event.text = msg # Fall through to normal message processing with skill content + else: + # Not an active skill — check if it's a known-but-disabled or + # uninstalled skill and give actionable guidance. + _unavail_msg = _check_unavailable_skill(command) + if _unavail_msg: + return _unavail_msg except Exception as e: logger.debug("Skill command check failed (non-fatal): %s", e) @@ -3065,12 +3118,69 @@ class GatewayRunner: from agent.skill_commands import get_skill_commands skill_cmds = get_skill_commands() if skill_cmds: - lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):") - for cmd in sorted(skill_cmds): + lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):") + # Show first 10, then point to /commands for the rest + sorted_cmds = sorted(skill_cmds) + for cmd in sorted_cmds[:10]: lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") + if len(sorted_cmds) > 10: + lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.") except Exception: pass return "\n".join(lines) + + async def _handle_commands_command(self, event: MessageEvent) -> str: + """Handle /commands [page] - paginated list of all commands and skills.""" + from hermes_cli.commands import gateway_help_lines + + raw_args = event.get_command_args().strip() + if raw_args: + try: + requested_page = int(raw_args) + except ValueError: + return "Usage: `/commands [page]`" + else: + requested_page = 1 + + # Build combined entry list: built-in commands + skill commands + entries = list(gateway_help_lines()) + try: + from agent.skill_commands import get_skill_commands + skill_cmds = get_skill_commands() + if skill_cmds: + entries.append("") + entries.append("⚡ **Skill Commands**:") + for cmd in sorted(skill_cmds): + desc = skill_cmds[cmd].get("description", "").strip() or "Skill command" + entries.append(f"`{cmd}` — {desc}") + except Exception: + pass + + if not entries: + return "No commands available." + + from gateway.config import Platform + page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 + total_pages = max(1, (len(entries) + page_size - 1) // page_size) + page = max(1, min(requested_page, total_pages)) + start = (page - 1) * page_size + page_entries = entries[start:start + page_size] + + lines = [ + f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})", + "", + *page_entries, + ] + if total_pages > 1: + nav_parts = [] + if page > 1: + nav_parts.append(f"`/commands {page - 1}` ← prev") + if page < total_pages: + nav_parts.append(f"next → `/commands {page + 1}`") + lines.extend(["", " | ".join(nav_parts)]) + if page != requested_page: + lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") + return "\n".join(lines) async def _handle_provider_command(self, event: MessageEvent) -> str: """Handle /provider command - show available providers.""" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index d442f7f94d..b115dd6ca1 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -118,6 +118,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ "Tools & Skills", cli_only=True), # Info + CommandDef("commands", "Browse all commands and skills (paginated)", "Info", + gateway_only=True, args_hint="[page]"), CommandDef("help", "Show available commands", "Info"), CommandDef("usage", "Show token usage for the current session", "Info"), CommandDef("insights", "Show usage insights and analytics", "Info", @@ -361,6 +363,47 @@ def telegram_bot_commands() -> list[tuple[str, str]]: return result +def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: + """Return Telegram menu commands (built-in + active skills), capped to the Bot API limit. + + Built-in commands come first, then active skill commands. Commands beyond + ``max_commands`` remain callable in the gateway; they are just omitted from + Telegram's native slash-command picker. + + Returns: + (menu_commands, hidden_count) where hidden_count is the number of + commands omitted due to the cap. + """ + all_commands = list(telegram_bot_commands()) + + # Append active BUILT-IN skill commands only (not user-installed hub skills). + # User-installed skills stay accessible via /skills and by typing the command + # directly, but don't clutter the Telegram menu. + try: + from agent.skill_commands import get_skill_commands + from pathlib import Path + # The repo's built-in skills live under /skills/ + _repo_skills_dir = str(Path(__file__).resolve().parent.parent / "skills") + skill_cmds = get_skill_commands() + for cmd_key in sorted(skill_cmds): + info = skill_cmds[cmd_key] + # Only include skills whose SKILL.md is in the repo's skills/ dir + skill_path = info.get("skill_md_path", "") + if not skill_path.startswith(_repo_skills_dir): + continue + name = cmd_key.lstrip("/").replace("-", "_") + desc = info.get("description", "") + # Telegram descriptions max 256 chars + if len(desc) > 256: + desc = desc[:253] + "..." + all_commands.append((name, desc)) + except Exception: + pass + + hidden_count = max(0, len(all_commands) - max_commands) + return all_commands[:max_commands], hidden_count + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/skills/inference-sh/cli/SKILL.md b/optional-skills/devops/cli/SKILL.md similarity index 100% rename from skills/inference-sh/cli/SKILL.md rename to optional-skills/devops/cli/SKILL.md diff --git a/skills/inference-sh/cli/references/app-discovery.md b/optional-skills/devops/cli/references/app-discovery.md similarity index 100% rename from skills/inference-sh/cli/references/app-discovery.md rename to optional-skills/devops/cli/references/app-discovery.md diff --git a/skills/inference-sh/cli/references/authentication.md b/optional-skills/devops/cli/references/authentication.md similarity index 100% rename from skills/inference-sh/cli/references/authentication.md rename to optional-skills/devops/cli/references/authentication.md diff --git a/skills/inference-sh/cli/references/cli-reference.md b/optional-skills/devops/cli/references/cli-reference.md similarity index 100% rename from skills/inference-sh/cli/references/cli-reference.md rename to optional-skills/devops/cli/references/cli-reference.md diff --git a/skills/inference-sh/cli/references/running-apps.md b/optional-skills/devops/cli/references/running-apps.md similarity index 100% rename from skills/inference-sh/cli/references/running-apps.md rename to optional-skills/devops/cli/references/running-apps.md diff --git a/skills/mlops/training/accelerate/SKILL.md b/optional-skills/mlops/accelerate/SKILL.md similarity index 100% rename from skills/mlops/training/accelerate/SKILL.md rename to optional-skills/mlops/accelerate/SKILL.md diff --git a/skills/mlops/training/accelerate/references/custom-plugins.md b/optional-skills/mlops/accelerate/references/custom-plugins.md similarity index 100% rename from skills/mlops/training/accelerate/references/custom-plugins.md rename to optional-skills/mlops/accelerate/references/custom-plugins.md diff --git a/skills/mlops/training/accelerate/references/megatron-integration.md b/optional-skills/mlops/accelerate/references/megatron-integration.md similarity index 100% rename from skills/mlops/training/accelerate/references/megatron-integration.md rename to optional-skills/mlops/accelerate/references/megatron-integration.md diff --git a/skills/mlops/training/accelerate/references/performance.md b/optional-skills/mlops/accelerate/references/performance.md similarity index 100% rename from skills/mlops/training/accelerate/references/performance.md rename to optional-skills/mlops/accelerate/references/performance.md diff --git a/skills/mlops/vector-databases/chroma/SKILL.md b/optional-skills/mlops/chroma/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/chroma/SKILL.md rename to optional-skills/mlops/chroma/SKILL.md diff --git a/skills/mlops/vector-databases/chroma/references/integration.md b/optional-skills/mlops/chroma/references/integration.md similarity index 100% rename from skills/mlops/vector-databases/chroma/references/integration.md rename to optional-skills/mlops/chroma/references/integration.md diff --git a/skills/mlops/vector-databases/faiss/SKILL.md b/optional-skills/mlops/faiss/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/faiss/SKILL.md rename to optional-skills/mlops/faiss/SKILL.md diff --git a/skills/mlops/vector-databases/faiss/references/index_types.md b/optional-skills/mlops/faiss/references/index_types.md similarity index 100% rename from skills/mlops/vector-databases/faiss/references/index_types.md rename to optional-skills/mlops/faiss/references/index_types.md diff --git a/skills/mlops/training/flash-attention/SKILL.md b/optional-skills/mlops/flash-attention/SKILL.md similarity index 100% rename from skills/mlops/training/flash-attention/SKILL.md rename to optional-skills/mlops/flash-attention/SKILL.md diff --git a/skills/mlops/training/flash-attention/references/benchmarks.md b/optional-skills/mlops/flash-attention/references/benchmarks.md similarity index 100% rename from skills/mlops/training/flash-attention/references/benchmarks.md rename to optional-skills/mlops/flash-attention/references/benchmarks.md diff --git a/skills/mlops/training/flash-attention/references/transformers-integration.md b/optional-skills/mlops/flash-attention/references/transformers-integration.md similarity index 100% rename from skills/mlops/training/flash-attention/references/transformers-integration.md rename to optional-skills/mlops/flash-attention/references/transformers-integration.md diff --git a/skills/mlops/training/hermes-atropos-environments/SKILL.md b/optional-skills/mlops/hermes-atropos-environments/SKILL.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/SKILL.md rename to optional-skills/mlops/hermes-atropos-environments/SKILL.md diff --git a/skills/mlops/training/hermes-atropos-environments/references/agentresult-fields.md b/optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/references/agentresult-fields.md rename to optional-skills/mlops/hermes-atropos-environments/references/agentresult-fields.md diff --git a/skills/mlops/training/hermes-atropos-environments/references/atropos-base-env.md b/optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/references/atropos-base-env.md rename to optional-skills/mlops/hermes-atropos-environments/references/atropos-base-env.md diff --git a/skills/mlops/training/hermes-atropos-environments/references/usage-patterns.md b/optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md similarity index 100% rename from skills/mlops/training/hermes-atropos-environments/references/usage-patterns.md rename to optional-skills/mlops/hermes-atropos-environments/references/usage-patterns.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/SKILL.md b/optional-skills/mlops/huggingface-tokenizers/SKILL.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/SKILL.md rename to optional-skills/mlops/huggingface-tokenizers/SKILL.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/algorithms.md b/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/algorithms.md rename to optional-skills/mlops/huggingface-tokenizers/references/algorithms.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/integration.md b/optional-skills/mlops/huggingface-tokenizers/references/integration.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/integration.md rename to optional-skills/mlops/huggingface-tokenizers/references/integration.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/pipeline.md b/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/pipeline.md rename to optional-skills/mlops/huggingface-tokenizers/references/pipeline.md diff --git a/skills/mlops/evaluation/huggingface-tokenizers/references/training.md b/optional-skills/mlops/huggingface-tokenizers/references/training.md similarity index 100% rename from skills/mlops/evaluation/huggingface-tokenizers/references/training.md rename to optional-skills/mlops/huggingface-tokenizers/references/training.md diff --git a/skills/mlops/inference/instructor/SKILL.md b/optional-skills/mlops/instructor/SKILL.md similarity index 100% rename from skills/mlops/inference/instructor/SKILL.md rename to optional-skills/mlops/instructor/SKILL.md diff --git a/skills/mlops/inference/instructor/references/examples.md b/optional-skills/mlops/instructor/references/examples.md similarity index 100% rename from skills/mlops/inference/instructor/references/examples.md rename to optional-skills/mlops/instructor/references/examples.md diff --git a/skills/mlops/inference/instructor/references/providers.md b/optional-skills/mlops/instructor/references/providers.md similarity index 100% rename from skills/mlops/inference/instructor/references/providers.md rename to optional-skills/mlops/instructor/references/providers.md diff --git a/skills/mlops/inference/instructor/references/validation.md b/optional-skills/mlops/instructor/references/validation.md similarity index 100% rename from skills/mlops/inference/instructor/references/validation.md rename to optional-skills/mlops/instructor/references/validation.md diff --git a/skills/mlops/cloud/lambda-labs/SKILL.md b/optional-skills/mlops/lambda-labs/SKILL.md similarity index 100% rename from skills/mlops/cloud/lambda-labs/SKILL.md rename to optional-skills/mlops/lambda-labs/SKILL.md diff --git a/skills/mlops/cloud/lambda-labs/references/advanced-usage.md b/optional-skills/mlops/lambda-labs/references/advanced-usage.md similarity index 100% rename from skills/mlops/cloud/lambda-labs/references/advanced-usage.md rename to optional-skills/mlops/lambda-labs/references/advanced-usage.md diff --git a/skills/mlops/cloud/lambda-labs/references/troubleshooting.md b/optional-skills/mlops/lambda-labs/references/troubleshooting.md similarity index 100% rename from skills/mlops/cloud/lambda-labs/references/troubleshooting.md rename to optional-skills/mlops/lambda-labs/references/troubleshooting.md diff --git a/skills/mlops/models/llava/SKILL.md b/optional-skills/mlops/llava/SKILL.md similarity index 100% rename from skills/mlops/models/llava/SKILL.md rename to optional-skills/mlops/llava/SKILL.md diff --git a/skills/mlops/models/llava/references/training.md b/optional-skills/mlops/llava/references/training.md similarity index 100% rename from skills/mlops/models/llava/references/training.md rename to optional-skills/mlops/llava/references/training.md diff --git a/skills/mlops/evaluation/nemo-curator/SKILL.md b/optional-skills/mlops/nemo-curator/SKILL.md similarity index 100% rename from skills/mlops/evaluation/nemo-curator/SKILL.md rename to optional-skills/mlops/nemo-curator/SKILL.md diff --git a/skills/mlops/evaluation/nemo-curator/references/deduplication.md b/optional-skills/mlops/nemo-curator/references/deduplication.md similarity index 100% rename from skills/mlops/evaluation/nemo-curator/references/deduplication.md rename to optional-skills/mlops/nemo-curator/references/deduplication.md diff --git a/skills/mlops/evaluation/nemo-curator/references/filtering.md b/optional-skills/mlops/nemo-curator/references/filtering.md similarity index 100% rename from skills/mlops/evaluation/nemo-curator/references/filtering.md rename to optional-skills/mlops/nemo-curator/references/filtering.md diff --git a/skills/mlops/vector-databases/pinecone/SKILL.md b/optional-skills/mlops/pinecone/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/pinecone/SKILL.md rename to optional-skills/mlops/pinecone/SKILL.md diff --git a/skills/mlops/vector-databases/pinecone/references/deployment.md b/optional-skills/mlops/pinecone/references/deployment.md similarity index 100% rename from skills/mlops/vector-databases/pinecone/references/deployment.md rename to optional-skills/mlops/pinecone/references/deployment.md diff --git a/skills/mlops/training/pytorch-lightning/SKILL.md b/optional-skills/mlops/pytorch-lightning/SKILL.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/SKILL.md rename to optional-skills/mlops/pytorch-lightning/SKILL.md diff --git a/skills/mlops/training/pytorch-lightning/references/callbacks.md b/optional-skills/mlops/pytorch-lightning/references/callbacks.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/references/callbacks.md rename to optional-skills/mlops/pytorch-lightning/references/callbacks.md diff --git a/skills/mlops/training/pytorch-lightning/references/distributed.md b/optional-skills/mlops/pytorch-lightning/references/distributed.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/references/distributed.md rename to optional-skills/mlops/pytorch-lightning/references/distributed.md diff --git a/skills/mlops/training/pytorch-lightning/references/hyperparameter-tuning.md b/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md similarity index 100% rename from skills/mlops/training/pytorch-lightning/references/hyperparameter-tuning.md rename to optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md diff --git a/skills/mlops/vector-databases/qdrant/SKILL.md b/optional-skills/mlops/qdrant/SKILL.md similarity index 100% rename from skills/mlops/vector-databases/qdrant/SKILL.md rename to optional-skills/mlops/qdrant/SKILL.md diff --git a/skills/mlops/vector-databases/qdrant/references/advanced-usage.md b/optional-skills/mlops/qdrant/references/advanced-usage.md similarity index 100% rename from skills/mlops/vector-databases/qdrant/references/advanced-usage.md rename to optional-skills/mlops/qdrant/references/advanced-usage.md diff --git a/skills/mlops/vector-databases/qdrant/references/troubleshooting.md b/optional-skills/mlops/qdrant/references/troubleshooting.md similarity index 100% rename from skills/mlops/vector-databases/qdrant/references/troubleshooting.md rename to optional-skills/mlops/qdrant/references/troubleshooting.md diff --git a/skills/mlops/evaluation/saelens/SKILL.md b/optional-skills/mlops/saelens/SKILL.md similarity index 100% rename from skills/mlops/evaluation/saelens/SKILL.md rename to optional-skills/mlops/saelens/SKILL.md diff --git a/skills/mlops/evaluation/saelens/references/README.md b/optional-skills/mlops/saelens/references/README.md similarity index 100% rename from skills/mlops/evaluation/saelens/references/README.md rename to optional-skills/mlops/saelens/references/README.md diff --git a/skills/mlops/evaluation/saelens/references/api.md b/optional-skills/mlops/saelens/references/api.md similarity index 100% rename from skills/mlops/evaluation/saelens/references/api.md rename to optional-skills/mlops/saelens/references/api.md diff --git a/skills/mlops/evaluation/saelens/references/tutorials.md b/optional-skills/mlops/saelens/references/tutorials.md similarity index 100% rename from skills/mlops/evaluation/saelens/references/tutorials.md rename to optional-skills/mlops/saelens/references/tutorials.md diff --git a/skills/mlops/training/simpo/SKILL.md b/optional-skills/mlops/simpo/SKILL.md similarity index 100% rename from skills/mlops/training/simpo/SKILL.md rename to optional-skills/mlops/simpo/SKILL.md diff --git a/skills/mlops/training/simpo/references/datasets.md b/optional-skills/mlops/simpo/references/datasets.md similarity index 100% rename from skills/mlops/training/simpo/references/datasets.md rename to optional-skills/mlops/simpo/references/datasets.md diff --git a/skills/mlops/training/simpo/references/hyperparameters.md b/optional-skills/mlops/simpo/references/hyperparameters.md similarity index 100% rename from skills/mlops/training/simpo/references/hyperparameters.md rename to optional-skills/mlops/simpo/references/hyperparameters.md diff --git a/skills/mlops/training/simpo/references/loss-functions.md b/optional-skills/mlops/simpo/references/loss-functions.md similarity index 100% rename from skills/mlops/training/simpo/references/loss-functions.md rename to optional-skills/mlops/simpo/references/loss-functions.md diff --git a/skills/mlops/training/slime/SKILL.md b/optional-skills/mlops/slime/SKILL.md similarity index 100% rename from skills/mlops/training/slime/SKILL.md rename to optional-skills/mlops/slime/SKILL.md diff --git a/skills/mlops/training/slime/references/api-reference.md b/optional-skills/mlops/slime/references/api-reference.md similarity index 100% rename from skills/mlops/training/slime/references/api-reference.md rename to optional-skills/mlops/slime/references/api-reference.md diff --git a/skills/mlops/training/slime/references/troubleshooting.md b/optional-skills/mlops/slime/references/troubleshooting.md similarity index 100% rename from skills/mlops/training/slime/references/troubleshooting.md rename to optional-skills/mlops/slime/references/troubleshooting.md diff --git a/skills/mlops/inference/tensorrt-llm/SKILL.md b/optional-skills/mlops/tensorrt-llm/SKILL.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/SKILL.md rename to optional-skills/mlops/tensorrt-llm/SKILL.md diff --git a/skills/mlops/inference/tensorrt-llm/references/multi-gpu.md b/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/references/multi-gpu.md rename to optional-skills/mlops/tensorrt-llm/references/multi-gpu.md diff --git a/skills/mlops/inference/tensorrt-llm/references/optimization.md b/optional-skills/mlops/tensorrt-llm/references/optimization.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/references/optimization.md rename to optional-skills/mlops/tensorrt-llm/references/optimization.md diff --git a/skills/mlops/inference/tensorrt-llm/references/serving.md b/optional-skills/mlops/tensorrt-llm/references/serving.md similarity index 100% rename from skills/mlops/inference/tensorrt-llm/references/serving.md rename to optional-skills/mlops/tensorrt-llm/references/serving.md diff --git a/skills/mlops/training/torchtitan/SKILL.md b/optional-skills/mlops/torchtitan/SKILL.md similarity index 100% rename from skills/mlops/training/torchtitan/SKILL.md rename to optional-skills/mlops/torchtitan/SKILL.md diff --git a/skills/mlops/training/torchtitan/references/checkpoint.md b/optional-skills/mlops/torchtitan/references/checkpoint.md similarity index 100% rename from skills/mlops/training/torchtitan/references/checkpoint.md rename to optional-skills/mlops/torchtitan/references/checkpoint.md diff --git a/skills/mlops/training/torchtitan/references/custom-models.md b/optional-skills/mlops/torchtitan/references/custom-models.md similarity index 100% rename from skills/mlops/training/torchtitan/references/custom-models.md rename to optional-skills/mlops/torchtitan/references/custom-models.md diff --git a/skills/mlops/training/torchtitan/references/float8.md b/optional-skills/mlops/torchtitan/references/float8.md similarity index 100% rename from skills/mlops/training/torchtitan/references/float8.md rename to optional-skills/mlops/torchtitan/references/float8.md diff --git a/skills/mlops/training/torchtitan/references/fsdp.md b/optional-skills/mlops/torchtitan/references/fsdp.md similarity index 100% rename from skills/mlops/training/torchtitan/references/fsdp.md rename to optional-skills/mlops/torchtitan/references/fsdp.md diff --git a/skills/research/domain-intel/SKILL.md b/optional-skills/research/domain-intel/SKILL.md similarity index 100% rename from skills/research/domain-intel/SKILL.md rename to optional-skills/research/domain-intel/SKILL.md diff --git a/skills/research/domain-intel/scripts/domain_intel.py b/optional-skills/research/domain-intel/scripts/domain_intel.py similarity index 100% rename from skills/research/domain-intel/scripts/domain_intel.py rename to optional-skills/research/domain-intel/scripts/domain_intel.py diff --git a/skills/research/duckduckgo-search/SKILL.md b/optional-skills/research/duckduckgo-search/SKILL.md similarity index 100% rename from skills/research/duckduckgo-search/SKILL.md rename to optional-skills/research/duckduckgo-search/SKILL.md diff --git a/skills/research/duckduckgo-search/scripts/duckduckgo.sh b/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh similarity index 100% rename from skills/research/duckduckgo-search/scripts/duckduckgo.sh rename to optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh From 9fd78c7a8ebb5b4f74df2d881d0cc8b4a4b7ceff Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:01:13 -0700 Subject: [PATCH 31/52] fix: use SKILLS_DIR not repo path for Telegram menu skill filter (#4005) Skills are synced to ~/.hermes/skills/ (SKILLS_DIR), not the repo's skills/ directory. The previous filter compared against the repo path so no skills matched. Now checks SKILLS_DIR and excludes .hub/ subdirectory (user-installed hub skills). --- hermes_cli/commands.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index b115dd6ca1..26247c0660 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -381,16 +381,20 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str # directly, but don't clutter the Telegram menu. try: from agent.skill_commands import get_skill_commands - from pathlib import Path - # The repo's built-in skills live under /skills/ - _repo_skills_dir = str(Path(__file__).resolve().parent.parent / "skills") + from tools.skills_tool import SKILLS_DIR + # Built-in skills are synced to SKILLS_DIR (~/.hermes/skills/). + # Hub-installed skills go into SKILLS_DIR/.hub/. Exclude .hub/ skills + # from the menu — they're user-installed, not repo built-in. + _skills_dir = str(SKILLS_DIR.resolve()) + _hub_dir = str((SKILLS_DIR / ".hub").resolve()) skill_cmds = get_skill_commands() for cmd_key in sorted(skill_cmds): info = skill_cmds[cmd_key] - # Only include skills whose SKILL.md is in the repo's skills/ dir skill_path = info.get("skill_md_path", "") - if not skill_path.startswith(_repo_skills_dir): + if not skill_path.startswith(_skills_dir): continue + if skill_path.startswith(_hub_dir): + continue # hub-installed, not built-in name = cmd_key.lstrip("/").replace("-", "_") desc = info.get("description", "") # Telegram descriptions max 256 chars From da3e22bcfa2c583204cbe0742a6b691d9b681da5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:05:20 -0700 Subject: [PATCH 32/52] =?UTF-8?q?fix:=20cap=20Telegram=20menu=20at=2050=20?= =?UTF-8?q?commands=20=E2=80=94=20API=20rejects=20above=20~60=20(#4006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use SKILLS_DIR not repo path for Telegram menu skill filter Skills are synced to ~/.hermes/skills/ (SKILLS_DIR), not the repo's skills/ directory. The previous filter compared against the repo path so no skills matched. Now checks SKILLS_DIR and excludes .hub/ subdirectory (user-installed hub skills). * fix: cap Telegram menu at 50 commands — API rejects above ~60 Telegram's setMyCommands returns BOT_COMMANDS_TOO_MUCH when registering close to 100 commands despite docs claiming 100 is the limit. Metadata overhead causes rejection above ~60. Cap at 50 for reliability — remaining commands accessible via /commands. --- gateway/platforms/telegram.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 91223d7b71..ac3efd92f6 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -623,7 +623,9 @@ class TelegramAdapter(BasePlatformAdapter): try: from telegram import BotCommand from hermes_cli.commands import telegram_menu_commands - menu_commands, hidden_count = telegram_menu_commands(max_commands=100) + # Telegram docs say 100, but setMyCommands returns + # BOT_COMMANDS_TOO_MUCH above ~60 due to metadata overhead. + menu_commands, hidden_count = telegram_menu_commands(max_commands=50) await self._bot.set_my_commands([ BotCommand(name, desc) for name, desc in menu_commands ]) From 0976bf6cd0653a6097dd01cd2a15e160af9dda55 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:17:09 -0700 Subject: [PATCH 33/52] feat: add /yolo slash command to toggle dangerous command approvals (#3990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /yolo command that toggles HERMES_YOLO_MODE at runtime, skipping all dangerous command approval prompts for the current session. Works in both CLI and gateway (Telegram, Discord, etc.). - /yolo -> ON: all commands auto-approved, no confirmation prompts - /yolo -> OFF: normal approval flow restored The --yolo CLI flag already existed for launch-time opt-in. This adds the ability to toggle mid-session without restarting. Session-scoped — resets when the process ends. Uses the existing HERMES_YOLO_MODE env var that check_all_command_guards() already respects. --- cli.py | 13 +++++++++++++ gateway/run.py | 13 +++++++++++++ hermes_cli/commands.py | 2 ++ 3 files changed, 28 insertions(+) diff --git a/cli.py b/cli.py index 706221506c..223c40563d 100644 --- a/cli.py +++ b/cli.py @@ -3836,6 +3836,8 @@ class HermesCLI: self.console.print(f" Status bar {state}") elif canonical == "verbose": self._toggle_verbose() + elif canonical == "yolo": + self._toggle_yolo() elif canonical == "reasoning": self._handle_reasoning_command(cmd_original) elif canonical == "compress": @@ -4434,6 +4436,17 @@ class HermesCLI: } _cprint(labels.get(self.tool_progress_mode, "")) + def _toggle_yolo(self): + """Toggle YOLO mode — skip all dangerous command approval prompts.""" + import os + current = bool(os.environ.get("HERMES_YOLO_MODE")) + if current: + os.environ.pop("HERMES_YOLO_MODE", None) + self.console.print(" ⚠ YOLO mode [bold red]OFF[/] — dangerous commands will require approval.") + else: + os.environ["HERMES_YOLO_MODE"] = "1" + self.console.print(" ⚡ YOLO mode [bold green]ON[/] — all commands auto-approved. Use with caution.") + def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. diff --git a/gateway/run.py b/gateway/run.py index 2bd623b62a..de077ede87 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1877,6 +1877,9 @@ class GatewayRunner: if canonical == "verbose": return await self._handle_verbose_command(event) + if canonical == "yolo": + return await self._handle_yolo_command(event) + if canonical == "provider": return await self._handle_provider_command(event) @@ -4109,6 +4112,16 @@ class GatewayRunner: else: return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" + async def _handle_yolo_command(self, event: MessageEvent) -> str: + """Handle /yolo — toggle dangerous command approval bypass.""" + current = bool(os.environ.get("HERMES_YOLO_MODE")) + if current: + os.environ.pop("HERMES_YOLO_MODE", None) + return "⚠️ YOLO mode **OFF** — dangerous commands will require approval." + else: + os.environ["HERMES_YOLO_MODE"] = "1" + return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution." + async def _handle_verbose_command(self, event: MessageEvent) -> str: """Handle /verbose command — cycle tool progress display mode. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 26247c0660..f043ec73fa 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -90,6 +90,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), + CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)", + "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]", subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), From f3069c649ca7c16692a54fb1434a8c29b894f4a7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:17:15 -0700 Subject: [PATCH 34/52] fix(cli): add missing subprocess.run() timeouts in doctor and status (#4009) Add timeout parameters to 4 subprocess.run() calls that could hang indefinitely if the child process blocks (e.g., unresponsive docker daemon, systemctl waiting for D-Bus): - doctor.py: docker info (timeout=10), ssh check (timeout=15) - status.py: systemctl is-active (timeout=5), launchctl list (timeout=5) Each call site now catches subprocess.TimeoutExpired and treats it as a failure, consistent with how non-zero return codes are already handled. Add AST-based regression test that verifies every subprocess.run() call in CLI modules specifies a timeout keyword argument. Co-authored-by: dieutx --- hermes_cli/doctor.py | 23 ++++++---- hermes_cli/status.py | 32 ++++++++------ tests/hermes_cli/test_subprocess_timeouts.py | 44 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 tests/hermes_cli/test_subprocess_timeouts.py diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index a0a8419053..b9fd8d3270 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -406,8 +406,11 @@ def run_doctor(args): if terminal_env == "docker": if shutil.which("docker"): # Check if docker daemon is running - result = subprocess.run(["docker", "info"], capture_output=True) - if result.returncode == 0: + try: + result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) + except subprocess.TimeoutExpired: + result = None + if result is not None and result.returncode == 0: check_ok("docker", "(daemon running)") else: check_fail("docker daemon not running") @@ -426,12 +429,16 @@ def run_doctor(args): ssh_host = os.getenv("TERMINAL_SSH_HOST") if ssh_host: # Try to connect - result = subprocess.run( - ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"], - capture_output=True, - text=True - ) - if result.returncode == 0: + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"], + capture_output=True, + text=True, + timeout=15 + ) + except subprocess.TimeoutExpired: + result = None + if result is not None and result.returncode == 0: check_ok(f"SSH connection to {ssh_host}") else: check_fail(f"SSH connection to {ssh_host}") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 3a03aabb16..aeb159a556 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -285,23 +285,31 @@ def show_status(args): _gw_svc = get_service_name() except Exception: _gw_svc = "hermes-gateway" - result = subprocess.run( - ["systemctl", "--user", "is-active", _gw_svc], - capture_output=True, - text=True - ) - is_active = result.stdout.strip() == "active" + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", _gw_svc], + capture_output=True, + text=True, + timeout=5 + ) + is_active = result.stdout.strip() == "active" + except subprocess.TimeoutExpired: + is_active = False print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") print(" Manager: systemd (user)") elif sys.platform == 'darwin': from hermes_cli.gateway import get_launchd_label - result = subprocess.run( - ["launchctl", "list", get_launchd_label()], - capture_output=True, - text=True - ) - is_loaded = result.returncode == 0 + try: + result = subprocess.run( + ["launchctl", "list", get_launchd_label()], + capture_output=True, + text=True, + timeout=5 + ) + is_loaded = result.returncode == 0 + except subprocess.TimeoutExpired: + is_loaded = False print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}") print(" Manager: launchd") else: diff --git a/tests/hermes_cli/test_subprocess_timeouts.py b/tests/hermes_cli/test_subprocess_timeouts.py new file mode 100644 index 0000000000..47146aac44 --- /dev/null +++ b/tests/hermes_cli/test_subprocess_timeouts.py @@ -0,0 +1,44 @@ +"""Tests for subprocess.run() timeout coverage in CLI utilities.""" +import ast +from pathlib import Path + +import pytest + + +# Parameterise over every CLI module that calls subprocess.run +_CLI_MODULES = [ + "hermes_cli/doctor.py", + "hermes_cli/status.py", + "hermes_cli/clipboard.py", + "hermes_cli/banner.py", +] + + +def _subprocess_run_calls(filepath: str) -> list[dict]: + """Parse a Python file and return info about subprocess.run() calls.""" + source = Path(filepath).read_text() + tree = ast.parse(source, filename=filepath) + calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if (isinstance(func, ast.Attribute) and func.attr == "run" + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess"): + has_timeout = any(kw.arg == "timeout" for kw in node.keywords) + calls.append({"line": node.lineno, "has_timeout": has_timeout}) + return calls + + +@pytest.mark.parametrize("filepath", _CLI_MODULES) +def test_all_subprocess_run_calls_have_timeout(filepath): + """Every subprocess.run() call in CLI modules must specify a timeout.""" + if not Path(filepath).exists(): + pytest.skip(f"{filepath} not found") + calls = _subprocess_run_calls(filepath) + missing = [c for c in calls if not c["has_timeout"]] + assert not missing, ( + f"{filepath} has subprocess.run() without timeout at " + f"line(s): {[c['line'] for c in missing]}" + ) From 60ecde8ac7d4b6b82bb80b411629947d0993d88b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:21:13 -0700 Subject: [PATCH 35/52] fix: fit all 100 commands in Telegram menu with 40-char descriptions (#4010) * fix: truncate skill descriptions to 100 chars in Telegram menu * fix: 40-char desc cap + 100 command limit for Telegram menu setMyCommands has an undocumented total payload size limit. 50 commands with 256-char descriptions failed, 50 with 100-char worked, and 100 with 40-char descriptions also works (~5300 total chars). Truncate skill descriptions to 40 chars in the menu picker and set cap back to 100. Full descriptions available via /commands. --- gateway/platforms/telegram.py | 7 ++++--- hermes_cli/commands.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index ac3efd92f6..db1b19431c 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -623,9 +623,10 @@ class TelegramAdapter(BasePlatformAdapter): try: from telegram import BotCommand from hermes_cli.commands import telegram_menu_commands - # Telegram docs say 100, but setMyCommands returns - # BOT_COMMANDS_TOO_MUCH above ~60 due to metadata overhead. - menu_commands, hidden_count = telegram_menu_commands(max_commands=50) + # Telegram allows up to 100 commands but has an undocumented + # payload size limit. Skill descriptions are truncated to 40 + # chars in telegram_menu_commands() to fit 100 commands safely. + menu_commands, hidden_count = telegram_menu_commands(max_commands=100) await self._bot.set_my_commands([ BotCommand(name, desc) for name, desc in menu_commands ]) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f043ec73fa..a14432624d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -399,9 +399,10 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str continue # hub-installed, not built-in name = cmd_key.lstrip("/").replace("-", "_") desc = info.get("description", "") - # Telegram descriptions max 256 chars - if len(desc) > 256: - desc = desc[:253] + "..." + # Keep descriptions short — setMyCommands has an undocumented + # total payload limit. 40 chars fits 100 commands safely. + if len(desc) > 40: + desc = desc[:37] + "..." all_commands.append((name, desc)) except Exception: pass From ea342f238209d99285a0780da5167e902d02e2e4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:24:10 -0700 Subject: [PATCH 36/52] Fix banner alignment in installer script (#4011) Co-authored-by: Ahmed Khaled --- scripts/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 6fbb22b45b..d46771e6aa 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -94,7 +94,7 @@ print_banner() { echo "" echo -e "${MAGENTA}${BOLD}" echo "┌─────────────────────────────────────────────────────────┐" - echo "│ ⚕ Hermes Agent Installer │" + echo "│ ⚕ Hermes Agent Installer │" echo "├─────────────────────────────────────────────────────────┤" echo "│ An open source AI agent by Nous Research. │" echo "└─────────────────────────────────────────────────────────┘" From 86250a3e45ffe9c1a6f3e60b6d8a0cd49c366e53 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:59:58 -0700 Subject: [PATCH 37/52] docs: expand terminal backends section + fix docs build (#4016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(telegram): add webhook mode as alternative to polling When TELEGRAM_WEBHOOK_URL is set, the adapter starts an HTTP webhook server (via python-telegram-bot's start_webhook()) instead of long polling. This enables cloud platforms like Fly.io and Railway to auto-wake suspended machines on inbound HTTP traffic. Polling remains the default — no behavior change unless the env var is set. Env vars: TELEGRAM_WEBHOOK_URL Public HTTPS URL for Telegram to push to TELEGRAM_WEBHOOK_PORT Local listen port (default 8443) TELEGRAM_WEBHOOK_SECRET Secret token for update verification Cherry-picked and adapted from PR #2022 by SHL0MS. Preserved all current main enhancements (network error recovery, polling conflict detection, DM topics setup). Co-authored-by: SHL0MS * fix: send_document call in background task delivery + vision download timeout Two fixes salvaged from PR #2269 by amethystani: 1. gateway/run.py: adapter.send_file() → adapter.send_document() send_file() doesn't exist on BasePlatformAdapter. Background task media files were silently never delivered (AttributeError swallowed by except Exception: pass). 2. tools/vision_tools.py: configurable image download timeout via HERMES_VISION_DOWNLOAD_TIMEOUT env var (default 30s), plus guard against raise None when max_retries=0. The third fix in #2269 (opencode-go auth config) was already resolved on main. Co-authored-by: amethystani * docs: expand terminal backends section + fix feishu MDX build error --------- Co-authored-by: SHL0MS Co-authored-by: amethystani --- website/docs/user-guide/configuration.md | 206 +++++++++++++++++------ 1 file changed, 156 insertions(+), 50 deletions(-) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 48d76dd80b..c3aa96f53c 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -699,65 +699,171 @@ Use this when you want lower latency or cost without fully changing your default ## Terminal Backend Configuration -Configure which environment the agent uses for terminal commands: +Hermes supports six terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, or a Singularity/Apptainer container. ```yaml terminal: - backend: local # or: docker, ssh, singularity, modal, daytona - cwd: "." # Working directory ("." = current dir) - timeout: 180 # Command timeout in seconds - - # Docker-specific settings - docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" - docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into /workspace. - docker_forward_env: # Optional explicit allowlist for env passthrough - - "GITHUB_TOKEN" - docker_volumes: # Additional explicit host mounts - - "/home/user/projects:/workspace/projects" - - "/home/user/data:/data:ro" # :ro for read-only - - # Container resource limits (docker, singularity, modal, daytona) - container_cpu: 1 # CPU cores - container_memory: 5120 # MB (default 5GB) - container_disk: 51200 # MB (default 50GB) - container_persistent: true # Persist filesystem across sessions - - # Persistent shell — keep a long-lived bash process across commands - persistent_shell: true # Enabled by default for SSH backend + backend: local # local | docker | ssh | modal | daytona | singularity + cwd: "." # Working directory ("." = current dir for local, "/root" for containers) + timeout: 180 # Per-command timeout in seconds ``` +### Backend Overview + +| Backend | Where commands run | Isolation | Best for | +|---------|-------------------|-----------|----------| +| **local** | Your machine directly | None | Development, personal use | +| **docker** | Docker container | Full (namespaces, cap-drop) | Safe sandboxing, CI/CD | +| **ssh** | Remote server via SSH | Network boundary | Remote dev, powerful hardware | +| **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | +| **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | +| **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | + +### Local Backend + +The default. Commands run directly on your machine with no isolation. No special setup required. + +```yaml +terminal: + backend: local +``` + +:::warning +The agent has the same filesystem access as your user account. Use `hermes tools` to disable tools you don't want, or switch to Docker for sandboxing. +::: + +### Docker Backend + +Runs commands inside a Docker container with security hardening (all capabilities dropped, no privilege escalation, PID limits). + +```yaml +terminal: + backend: docker + docker_image: "nikolaik/python-nodejs:python3.11-nodejs20" + docker_mount_cwd_to_workspace: false # Mount launch dir into /workspace + docker_forward_env: # Env vars to forward into container + - "GITHUB_TOKEN" + docker_volumes: # Host directory mounts + - "/home/user/projects:/workspace/projects" + - "/home/user/data:/data:ro" # :ro for read-only + + # Resource limits + container_cpu: 1 # CPU cores (0 = unlimited) + container_memory: 5120 # MB (0 = unlimited) + container_disk: 51200 # MB (requires overlay2 on XFS+pquota) + container_persistent: true # Persist /workspace and /root across sessions +``` + +**Requirements:** Docker Desktop or Docker Engine installed and running. Hermes probes `$PATH` plus common macOS install locations (`/usr/local/bin/docker`, `/opt/homebrew/bin/docker`, Docker Desktop app bundle). + +**Container lifecycle:** Each session starts a long-lived container (`docker run -d ... sleep 2h`). Commands run via `docker exec` with a login shell. On cleanup, the container is stopped and removed. + +**Security hardening:** +- `--cap-drop ALL` with only `DAC_OVERRIDE`, `CHOWN`, `FOWNER` added back +- `--security-opt no-new-privileges` +- `--pids-limit 256` +- Size-limited tmpfs for `/tmp` (512MB), `/var/tmp` (256MB), `/run` (64MB) + +**Credential forwarding:** Env vars listed in `docker_forward_env` are resolved from your shell environment first, then `~/.hermes/.env`. Skills can also declare `required_environment_variables` which are merged automatically. + +### SSH Backend + +Runs commands on a remote server over SSH. Uses ControlMaster for connection reuse (5-minute idle keepalive). Persistent shell is enabled by default — state (cwd, env vars) survives across commands. + +```yaml +terminal: + backend: ssh + persistent_shell: true # Keep a long-lived bash session (default: true) +``` + +**Required environment variables:** + +```bash +TERMINAL_SSH_HOST=my-server.example.com +TERMINAL_SSH_USER=ubuntu +``` + +**Optional:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `TERMINAL_SSH_PORT` | `22` | SSH port | +| `TERMINAL_SSH_KEY` | (system default) | Path to SSH private key | +| `TERMINAL_SSH_PERSISTENT` | `true` | Enable persistent shell | + +**How it works:** Connects at init time with `BatchMode=yes` and `StrictHostKeyChecking=accept-new`. Persistent shell keeps a single `bash -l` process alive on the remote host, communicating via temporary files. Commands that need `stdin_data` or `sudo` automatically fall back to one-shot mode. + +### Modal Backend + +Runs commands in a [Modal](https://modal.com) cloud sandbox. Each task gets an isolated VM with configurable CPU, memory, and disk. Filesystem can be snapshot/restored across sessions. + +```yaml +terminal: + backend: modal + container_cpu: 1 # CPU cores + container_memory: 5120 # MB (5GB) + container_disk: 51200 # MB (50GB) + container_persistent: true # Snapshot/restore filesystem +``` + +**Required:** Either `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` environment variables, or a `~/.modal.toml` config file. + +**Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`. + +**Credential files:** Automatically mounted from `~/.hermes/` (OAuth tokens, etc.) and synced before each command. + +### Daytona Backend + +Runs commands in a [Daytona](https://daytona.io) managed workspace. Supports stop/resume for persistence. + +```yaml +terminal: + backend: daytona + container_cpu: 1 # CPU cores + container_memory: 5120 # MB → converted to GiB + container_disk: 10240 # MB → converted to GiB (max 10 GiB) + container_persistent: true # Stop/resume instead of delete +``` + +**Required:** `DAYTONA_API_KEY` environment variable. + +**Persistence:** When enabled, sandboxes are stopped (not deleted) on cleanup and resumed on next session. Sandbox names follow the pattern `hermes-{task_id}`. + +**Disk limit:** Daytona enforces a 10 GiB maximum. Requests above this are capped with a warning. + +### Singularity/Apptainer Backend + +Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available. + +```yaml +terminal: + backend: singularity + singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" + container_cpu: 1 # CPU cores + container_memory: 5120 # MB + container_persistent: true # Writable overlay persists across sessions +``` + +**Requirements:** `apptainer` or `singularity` binary in `$PATH`. + +**Image handling:** Docker URLs (`docker://...`) are automatically converted to SIF files and cached. Existing `.sif` files are used directly. + +**Scratch directory:** Resolved in order: `TERMINAL_SCRATCH_DIR` → `TERMINAL_SANDBOX_DIR/singularity` → `/scratch/$USER/hermes-agent` (HPC convention) → `~/.hermes/sandboxes/singularity`. + +**Isolation:** Uses `--containall --no-home` for full namespace isolation without mounting the host home directory. + ### Common Terminal Backend Issues -If terminal commands fail immediately or the terminal tool is reported as disabled, check the following: +If terminal commands fail immediately or the terminal tool is reported as disabled: -- **Local backend** - - No special requirements. This is the safest default when you are just getting started. +- **Local** — No special requirements. The safest default when getting started. +- **Docker** — Run `docker version` to verify Docker is working. If it fails, fix Docker or `hermes config set terminal.backend local`. +- **SSH** — Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set. Hermes logs a clear error if either is missing. +- **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check. +- **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration. +- **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters. -- **Docker backend** - - Ensure Docker Desktop (or the Docker daemon) is installed and running. - - Hermes needs to be able to find the `docker` CLI. It checks your `$PATH` first and also probes common Docker Desktop install locations on macOS. Run: - ```bash - docker version - ``` - If this fails, fix your Docker installation or switch back to the local backend: - ```bash - hermes config set terminal.backend local - ``` - -- **SSH backend** - - Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set, for example: - ```bash - export TERMINAL_ENV=ssh - export TERMINAL_SSH_HOST=my-server.example.com - export TERMINAL_SSH_USER=ubuntu - ``` - - If either value is missing, Hermes will log a clear error and refuse to use the SSH backend. - -- **Modal backend** - - You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file. - - If neither is present, the backend check fails and Hermes will report that the Modal backend is not available. - -When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. +When in doubt, set `terminal.backend` back to `local` and verify commands run there first. ### Docker Volume Mounts From 158f49f19a6bb8dfd818f477ade43e3800a3178e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:04:06 -0700 Subject: [PATCH 38/52] =?UTF-8?q?fix:=20enforce=20priority=20order=20in=20?= =?UTF-8?q?Telegram=20menu=20=E2=80=94=20core=20>=20plugins=20>=20skills?= =?UTF-8?q?=20(#4023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu now has explicit priority tiers: 1. Core CommandDef commands (always included, never bumped) 2. Plugin slash commands (take precedence over skills) 3. Built-in skill commands (fill remaining slots alphabetically) Only skills get trimmed when the 100-command cap is hit. Adding new core commands or plugin commands automatically pushes skills out, not the other way around. --- hermes_cli/commands.py | 45 +++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a14432624d..3b1eb37ff5 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -366,27 +366,41 @@ def telegram_bot_commands() -> list[tuple[str, str]]: def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: - """Return Telegram menu commands (built-in + active skills), capped to the Bot API limit. + """Return Telegram menu commands capped to the Bot API limit. - Built-in commands come first, then active skill commands. Commands beyond - ``max_commands`` remain callable in the gateway; they are just omitted from - Telegram's native slash-command picker. + Priority order (higher priority = never bumped by overflow): + 1. Core CommandDef commands (always included) + 2. Plugin slash commands (take precedence over skills) + 3. Built-in skill commands (fill remaining slots, alphabetical) + + Skills are the only tier that gets trimmed when the cap is hit. + User-installed hub skills are excluded — accessible via /skills. Returns: (menu_commands, hidden_count) where hidden_count is the number of - commands omitted due to the cap. + skill commands omitted due to the cap. """ all_commands = list(telegram_bot_commands()) - # Append active BUILT-IN skill commands only (not user-installed hub skills). - # User-installed skills stay accessible via /skills and by typing the command - # directly, but don't clutter the Telegram menu. + # Plugin slash commands get priority over skills + try: + from hermes_cli.plugins import get_plugin_manager + pm = get_plugin_manager() + plugin_cmds = getattr(pm, "_plugin_commands", {}) + for cmd_name in sorted(plugin_cmds): + tg_name = cmd_name.replace("-", "_") + desc = "Plugin command" + if len(desc) > 40: + desc = desc[:37] + "..." + all_commands.append((tg_name, desc)) + except Exception: + pass + + # Remaining slots go to built-in skill commands (not hub-installed). + skill_entries: list[tuple[str, str]] = [] try: from agent.skill_commands import get_skill_commands from tools.skills_tool import SKILLS_DIR - # Built-in skills are synced to SKILLS_DIR (~/.hermes/skills/). - # Hub-installed skills go into SKILLS_DIR/.hub/. Exclude .hub/ skills - # from the menu — they're user-installed, not repo built-in. _skills_dir = str(SKILLS_DIR.resolve()) _hub_dir = str((SKILLS_DIR / ".hub").resolve()) skill_cmds = get_skill_commands() @@ -396,18 +410,21 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str if not skill_path.startswith(_skills_dir): continue if skill_path.startswith(_hub_dir): - continue # hub-installed, not built-in + continue name = cmd_key.lstrip("/").replace("-", "_") desc = info.get("description", "") # Keep descriptions short — setMyCommands has an undocumented # total payload limit. 40 chars fits 100 commands safely. if len(desc) > 40: desc = desc[:37] + "..." - all_commands.append((name, desc)) + skill_entries.append((name, desc)) except Exception: pass - hidden_count = max(0, len(all_commands) - max_commands) + # Skills fill remaining slots — they're the only tier that gets trimmed + remaining_slots = max(0, max_commands - len(all_commands)) + hidden_count = max(0, len(skill_entries) - remaining_slots) + all_commands.extend(skill_entries[:remaining_slots]) return all_commands[:max_commands], hidden_count From ed9af6e5892f6e33d75c4de5efa7cc8110c281f9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:16:16 -0700 Subject: [PATCH 39/52] fix: create AsyncOpenAI lazily in trajectory_compressor to avoid closed event loop (#4013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AsyncOpenAI client was created once at __init__ and stored as an instance attribute. process_directory() calls asyncio.run() which creates and closes a fresh event loop. On a second call, the client's httpx transport is still bound to the closed loop, raising RuntimeError: "Event loop is closed" — the same pattern fixed by PR #3398 for the main agent loop. Create the client lazily in _get_async_client() so each asyncio.run() gets a client bound to the current loop. Co-authored-by: binhnt92 --- tests/test_trajectory_compressor_async.py | 115 ++++++++++++++++++++++ trajectory_compressor.py | 27 ++++- 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 tests/test_trajectory_compressor_async.py diff --git a/tests/test_trajectory_compressor_async.py b/tests/test_trajectory_compressor_async.py new file mode 100644 index 0000000000..2b276d03d0 --- /dev/null +++ b/tests/test_trajectory_compressor_async.py @@ -0,0 +1,115 @@ +"""Tests for trajectory_compressor AsyncOpenAI event loop binding. + +The AsyncOpenAI client was created once at __init__ time and stored as an +instance attribute. When process_directory() calls asyncio.run() — which +creates and closes a fresh event loop — the client's internal httpx +transport remains bound to the now-closed loop. A second call to +process_directory() would fail with "Event loop is closed". + +The fix creates the AsyncOpenAI client lazily via _get_async_client() so +each asyncio.run() gets a client bound to the current loop. +""" + +import types +from unittest.mock import MagicMock, patch + +import pytest + + +class TestAsyncClientLazyCreation: + """trajectory_compressor.py — _get_async_client()""" + + def test_async_client_none_after_init(self): + """async_client should be None after __init__ (not eagerly created).""" + from trajectory_compressor import TrajectoryCompressor + + comp = TrajectoryCompressor.__new__(TrajectoryCompressor) + comp.config = MagicMock() + comp.config.base_url = "https://api.example.com/v1" + comp.config.api_key_env = "TEST_API_KEY" + comp._use_call_llm = False + comp.async_client = None + comp._async_client_api_key = "test-key" + + assert comp.async_client is None + + def test_get_async_client_creates_new_client(self): + """_get_async_client() should create a fresh AsyncOpenAI instance.""" + from trajectory_compressor import TrajectoryCompressor + + comp = TrajectoryCompressor.__new__(TrajectoryCompressor) + comp.config = MagicMock() + comp.config.base_url = "https://api.example.com/v1" + comp._async_client_api_key = "test-key" + comp.async_client = None + + mock_async_openai = MagicMock() + with patch("openai.AsyncOpenAI", mock_async_openai): + client = comp._get_async_client() + + mock_async_openai.assert_called_once_with( + api_key="test-key", + base_url="https://api.example.com/v1", + ) + assert comp.async_client is not None + + def test_get_async_client_creates_fresh_each_call(self): + """Each call to _get_async_client() creates a NEW client instance, + so it binds to the current event loop.""" + from trajectory_compressor import TrajectoryCompressor + + comp = TrajectoryCompressor.__new__(TrajectoryCompressor) + comp.config = MagicMock() + comp.config.base_url = "https://api.example.com/v1" + comp._async_client_api_key = "test-key" + comp.async_client = None + + call_count = 0 + instances = [] + + def mock_constructor(**kwargs): + nonlocal call_count + call_count += 1 + instance = MagicMock() + instances.append(instance) + return instance + + with patch("openai.AsyncOpenAI", side_effect=mock_constructor): + client1 = comp._get_async_client() + client2 = comp._get_async_client() + + # Should have created two separate instances + assert call_count == 2 + assert instances[0] is not instances[1] + + +class TestSourceLineVerification: + """Verify the actual source has the lazy pattern applied.""" + + @staticmethod + def _read_file() -> str: + import os + base = os.path.dirname(os.path.dirname(__file__)) + with open(os.path.join(base, "trajectory_compressor.py")) as f: + return f.read() + + def test_no_eager_async_openai_in_init(self): + """__init__ should NOT create AsyncOpenAI eagerly.""" + src = self._read_file() + # The old pattern: self.async_client = AsyncOpenAI(...) in _init_summarizer + # should not exist — only self.async_client = None + lines = src.split("\n") + for i, line in enumerate(lines, 1): + if "self.async_client = AsyncOpenAI(" in line and "_get_async_client" not in lines[max(0,i-3):i+1]: + # Allow it inside _get_async_client method + # Check if we're inside _get_async_client by looking at context + context = "\n".join(lines[max(0,i-10):i+1]) + if "_get_async_client" not in context: + pytest.fail( + f"Line {i}: AsyncOpenAI created eagerly outside _get_async_client()" + ) + + def test_get_async_client_method_exists(self): + """_get_async_client method should exist.""" + src = self._read_file() + assert "def _get_async_client(self)" in src diff --git a/trajectory_compressor.py b/trajectory_compressor.py index fd69cd18a6..2dfdda7af3 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -375,15 +375,34 @@ class TrajectoryCompressor: raise RuntimeError( f"Missing API key. Set {self.config.api_key_env} " f"environment variable.") - from openai import OpenAI, AsyncOpenAI + from openai import OpenAI self.client = OpenAI( api_key=api_key, base_url=self.config.base_url) - self.async_client = AsyncOpenAI( - api_key=api_key, base_url=self.config.base_url) + # AsyncOpenAI is created lazily in _get_async_client() so it + # binds to the current event loop — avoids "Event loop is closed" + # when process_directory() is called multiple times (each call + # creates a new loop via asyncio.run()). + self.async_client = None + self._async_client_api_key = api_key print(f"✅ Initialized summarizer client: {self.config.summarization_model}") print(f" Max concurrent requests: {self.config.max_concurrent_requests}") + def _get_async_client(self): + """Return an AsyncOpenAI client bound to the current event loop. + + Created lazily so that each ``asyncio.run()`` call in + ``process_directory()`` gets a client tied to its own loop, + avoiding "Event loop is closed" errors on repeated calls. + """ + from openai import AsyncOpenAI + # Always create a fresh client so it binds to the running loop. + self.async_client = AsyncOpenAI( + api_key=self._async_client_api_key, + base_url=self.config.base_url, + ) + return self.async_client + def _detect_provider(self) -> str: """Detect the provider name from the configured base_url.""" url = (self.config.base_url or "").lower() @@ -615,7 +634,7 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.""" max_tokens=self.config.summary_target_tokens * 2, ) else: - response = await self.async_client.chat.completions.create( + response = await self._get_async_client().chat.completions.create( model=self.config.summarization_model, messages=[{"role": "user", "content": prompt}], temperature=self.config.temperature, From 7dac75f2ae0773b18e8088b678355c59dd164aa0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:18:21 -0700 Subject: [PATCH 40/52] fix: prevent context pressure warning spam after compression (#4012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add /yolo slash command to toggle dangerous command approvals Adds a /yolo command that toggles HERMES_YOLO_MODE at runtime, skipping all dangerous command approval prompts for the current session. Works in both CLI and gateway (Telegram, Discord, etc.). - /yolo -> ON: all commands auto-approved, no confirmation prompts - /yolo -> OFF: normal approval flow restored The --yolo CLI flag already existed for launch-time opt-in. This adds the ability to toggle mid-session without restarting. Session-scoped — resets when the process ends. Uses the existing HERMES_YOLO_MODE env var that check_all_command_guards() already respects. * fix: prevent context pressure warning spam (agent loop + gateway rate-limit) Two complementary fixes for repeated context pressure warnings spamming gateway users (Telegram, Discord, etc.): 1. Agent-level loop fix (run_agent.py): After compression, only reset _context_pressure_warned if the post-compression estimate is actually below the 85% warning level. Previously the flag was unconditionally reset, causing the warning to re-fire every loop iteration when compression couldn't reduce below 85% of the threshold (e.g. very low threshold like 15%, or system prompt alone exceeds the warning level). 2. Gateway-level rate-limit (gateway/run.py, salvaged from PR #3786): Per-chat_id cooldown of 1 hour on compression warning messages. Both warning paths ('still large after compression' and 'compression failed') are gated. Defense-in-depth — even if the agent-level fix has edge cases, users won't see more than one warning per hour. Co-authored-by: dlkakbs --------- Co-authored-by: dlkakbs --- gateway/run.py | 19 ++++++++++-- run_agent.py | 17 +++++++---- tests/gateway/test_session_hygiene.py | 43 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index de077ede87..c85ed27b88 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -476,6 +476,13 @@ class GatewayRunner: self._honcho_managers: Dict[str, Any] = {} self._honcho_configs: Dict[str, Any] = {} + # Rate-limit compression warning messages sent to users. + # Keyed by chat_id — value is the timestamp of the last warning sent. + # Prevents the warning from firing on every message when a session + # remains above the threshold after compression. + self._compression_warn_sent: Dict[str, float] = {} + self._compression_warn_cooldown: int = 3600 # seconds (1 hour) + # Ensure tirith security scanner is available (downloads if needed) try: from tools.tirith_security import ensure_installed @@ -2400,13 +2407,18 @@ class GatewayRunner: pass # Still too large after compression — warn user + # Rate-limited to once per cooldown period per + # chat to avoid spamming on every message. if _new_tokens >= _warn_token_threshold: logger.warning( "Session hygiene: still ~%s tokens after " "compression — suggesting /reset", f"{_new_tokens:,}", ) - if _hyg_adapter: + _now = time.time() + _last_warn = self._compression_warn_sent.get(source.chat_id, 0) + if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown: + self._compression_warn_sent[source.chat_id] = _now try: await _hyg_adapter.send( source.chat_id, @@ -2428,7 +2440,10 @@ class GatewayRunner: if _approx_tokens >= _warn_token_threshold: _hyg_adapter = self.adapters.get(source.platform) _hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None - if _hyg_adapter: + _now = time.time() + _last_warn = self._compression_warn_sent.get(source.chat_id, 0) + if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown: + self._compression_warn_sent[source.chat_id] = _now try: await _hyg_adapter.send( source.chat_id, diff --git a/run_agent.py b/run_agent.py index 13eba7fe7c..794c9f67ab 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5221,11 +5221,8 @@ class AIAgent: except Exception as e: logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) - # Reset context pressure warning and token estimate — usage drops - # after compaction. Without this, the stale last_prompt_tokens from - # the previous API call causes the pressure calculation to stay at - # >1000% and spam warnings / re-trigger compression in a loop. - self._context_pressure_warned = False + # Update token estimate after compaction so pressure calculations + # use the post-compression count, not the stale pre-compression one. _compressed_est = ( estimate_tokens_rough(new_system_prompt) + estimate_messages_tokens_rough(compressed) @@ -5233,6 +5230,16 @@ class AIAgent: self.context_compressor.last_prompt_tokens = _compressed_est self.context_compressor.last_completion_tokens = 0 + # Only reset the pressure warning if compression actually brought + # us below the warning level (85% of threshold). When compression + # can't reduce enough (e.g. threshold is very low, or system prompt + # alone exceeds the warning level), keep the flag set to prevent + # spamming the user with repeated warnings every loop iteration. + if self.context_compressor.threshold_tokens > 0: + _post_progress = _compressed_est / self.context_compressor.threshold_tokens + if _post_progress < 0.85: + self._context_pressure_warned = False + return compressed, new_system_prompt def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index b8ff8f8a88..843c0d4167 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -212,6 +212,49 @@ class TestSessionHygieneWarnThreshold: assert post_compress_tokens < warn_threshold +class TestCompressionWarnRateLimit: + """Compression warning messages must be rate-limited per chat_id.""" + + def _make_runner(self): + from unittest.mock import MagicMock, patch + with patch("gateway.run.load_gateway_config"), \ + patch("gateway.run.SessionStore"), \ + patch("gateway.run.DeliveryRouter"): + from gateway.run import GatewayRunner + runner = GatewayRunner.__new__(GatewayRunner) + runner._compression_warn_sent = {} + runner._compression_warn_cooldown = 3600 + return runner + + def test_first_warn_is_sent(self): + runner = self._make_runner() + now = 1_000_000.0 + last = runner._compression_warn_sent.get("chat:1", 0) + assert now - last >= runner._compression_warn_cooldown + + def test_second_warn_suppressed_within_cooldown(self): + runner = self._make_runner() + now = 1_000_000.0 + runner._compression_warn_sent["chat:1"] = now - 60 # 1 minute ago + last = runner._compression_warn_sent.get("chat:1", 0) + assert now - last < runner._compression_warn_cooldown + + def test_warn_allowed_after_cooldown(self): + runner = self._make_runner() + now = 1_000_000.0 + runner._compression_warn_sent["chat:1"] = now - 3601 # just past cooldown + last = runner._compression_warn_sent.get("chat:1", 0) + assert now - last >= runner._compression_warn_cooldown + + def test_rate_limit_is_per_chat(self): + """Rate-limiting one chat must not suppress warnings for another.""" + runner = self._make_runner() + now = 1_000_000.0 + runner._compression_warn_sent["chat:1"] = now - 60 # suppressed + last_other = runner._compression_warn_sent.get("chat:2", 0) + assert now - last_other >= runner._compression_warn_cooldown + + class TestEstimatedTokenThreshold: """Verify that hygiene thresholds are always below the model's context limit — for both actual and estimated token counts. From 950f69475fd59d539ab0b8fc953c29ff170ebb88 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:18:42 -0700 Subject: [PATCH 41/52] feat(browser): add Camofox local anti-detection browser backend (#4008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camofox-browser is a self-hosted Node.js server wrapping Camoufox (Firefox fork with C++ fingerprint spoofing). When CAMOFOX_URL is set, all 11 browser tools route through the Camofox REST API instead of the agent-browser CLI. Maps 1:1 to the existing browser tool interface: - Navigate, snapshot, click, type, scroll, back, press, close - Get images, vision (screenshot + LLM analysis) - Console (returns empty with note — camofox limitation) Setup: npm start in camofox-browser dir, or docker run -p 9377:9377 Then: CAMOFOX_URL=http://localhost:9377 in ~/.hermes/.env Advantages over Browserbase (cloud): - Free (no per-session API costs) - Local (zero network latency for browser ops) - Anti-detection at C++ level (bypasses Cloudflare/Google bot detection) - Works offline, Docker-ready Files: - tools/browser_camofox.py: Full REST backend (~400 lines) - tools/browser_tool.py: Routing at each tool function - hermes_cli/config.py: CAMOFOX_URL env var entry - tests/tools/test_browser_camofox.py: 20 tests --- hermes_cli/config.py | 8 + hermes_cli/setup.py | 6 +- hermes_cli/tools_config.py | 32 ++ package.json | 3 +- tests/tools/test_browser_camofox.py | 290 ++++++++++++++++ tools/browser_camofox.py | 496 ++++++++++++++++++++++++++++ tools/browser_tool.py | 57 ++++ 7 files changed, 889 insertions(+), 3 deletions(-) create mode 100644 tests/tools/test_browser_camofox.py create mode 100644 tools/browser_camofox.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e2503ebec2..56d1026927 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -706,6 +706,14 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "tool", }, + "CAMOFOX_URL": { + "description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)", + "prompt": "Camofox server URL", + "url": "https://github.com/jo-inc/camofox-browser", + "tools": ["browser_navigate", "browser_click"], + "password": False, + "category": "tool", + }, "FAL_KEY": { "description": "FAL API key for image generation", "prompt": "FAL API key", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 35695144d3..304f34f569 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -601,13 +601,15 @@ def _print_setup_summary(config: dict, hermes_home): Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" ).exists() ) - if get_env_value("BROWSERBASE_API_KEY"): + if get_env_value("CAMOFOX_URL"): + tool_status.append(("Browser Automation (Camofox)", True, None)) + elif get_env_value("BROWSERBASE_API_KEY"): tool_status.append(("Browser Automation (Browserbase)", True, None)) elif _ab_found: tool_status.append(("Browser Automation (local)", True, None)) else: tool_status.append( - ("Browser Automation", False, "npm install -g agent-browser") + ("Browser Automation", False, "npm install -g agent-browser or set CAMOFOX_URL") ) # FAL (image generation) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 91496d45db..63e26d3627 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -273,6 +273,16 @@ TOOL_CATEGORIES = { "browser_provider": "browser-use", "post_setup": "browserbase", }, + { + "name": "Camofox", + "tag": "Local anti-detection browser (Firefox/Camoufox)", + "env_vars": [ + {"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", + "url": "https://github.com/jo-inc/camofox-browser"}, + ], + "browser_provider": "camofox", + "post_setup": "camofox", + }, ], }, "homeassistant": { @@ -337,6 +347,28 @@ def _run_post_setup(post_setup_key: str): elif not node_modules.exists(): _print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)") + elif post_setup_key == "camofox": + camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camoufox-browser" + if not camofox_dir.exists() and shutil.which("npm"): + _print_info(" Installing Camofox browser server...") + import subprocess + result = subprocess.run( + ["npm", "install", "--silent"], + capture_output=True, text=True, cwd=str(PROJECT_ROOT) + ) + if result.returncode == 0: + _print_success(" Camofox installed") + else: + _print_warning(" npm install failed - run manually: npm install") + if camofox_dir.exists(): + _print_info(" Start the Camofox server:") + _print_info(" npx @askjo/camoufox-browser") + _print_info(" First run downloads the Camoufox engine (~300MB)") + _print_info(" Or use Docker: docker run -p 9377:9377 jo-inc/camofox-browser") + elif not shutil.which("npm"): + _print_warning(" Node.js not found. Install Camofox via Docker:") + _print_info(" docker run -p 9377:9377 jo-inc/camofox-browser") + elif post_setup_key == "rl_training": try: __import__("tinker_atropos") diff --git a/package.json b/package.json index 5e593367b7..309217c822 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ }, "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "dependencies": { - "agent-browser": "^0.13.0" + "agent-browser": "^0.13.0", + "@askjo/camoufox-browser": "^1.0.0" }, "engines": { "node": ">=18.0.0" diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py new file mode 100644 index 0000000000..a59862b9bd --- /dev/null +++ b/tests/tools/test_browser_camofox.py @@ -0,0 +1,290 @@ +"""Tests for the Camofox browser backend.""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from tools.browser_camofox import ( + camofox_back, + camofox_click, + camofox_close, + camofox_console, + camofox_get_images, + camofox_navigate, + camofox_press, + camofox_scroll, + camofox_snapshot, + camofox_type, + camofox_vision, + check_camofox_available, + cleanup_all_camofox_sessions, + is_camofox_mode, +) + + +# --------------------------------------------------------------------------- +# Configuration detection +# --------------------------------------------------------------------------- + + +class TestCamofoxMode: + def test_disabled_by_default(self, monkeypatch): + monkeypatch.delenv("CAMOFOX_URL", raising=False) + assert is_camofox_mode() is False + + def test_enabled_when_url_set(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + assert is_camofox_mode() is True + + def test_health_check_unreachable(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") + assert check_camofox_available() is False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_response(status=200, json_data=None): + resp = MagicMock() + resp.status_code = status + resp.json.return_value = json_data or {} + resp.content = b"\x89PNG\r\n\x1a\nfake" + resp.raise_for_status = MagicMock() + return resp + + +# --------------------------------------------------------------------------- +# Navigate +# --------------------------------------------------------------------------- + + +class TestCamofoxNavigate: + @patch("tools.browser_camofox.requests.post") + def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"}) + + result = json.loads(camofox_navigate("https://example.com", task_id="t1")) + assert result["success"] is True + assert result["url"] == "https://example.com" + + @patch("tools.browser_camofox.requests.post") + def test_navigates_existing_tab(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + # First call creates tab + mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"}) + camofox_navigate("https://a.com", task_id="t2") + + # Second call navigates + mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"}) + result = json.loads(camofox_navigate("https://b.com", task_id="t2")) + assert result["success"] is True + assert result["url"] == "https://b.com" + + def test_connection_error_returns_helpful_message(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") + result = json.loads(camofox_navigate("https://example.com", task_id="t_err")) + assert result["success"] is False + assert "Cannot connect" in result["error"] + + +# --------------------------------------------------------------------------- +# Snapshot +# --------------------------------------------------------------------------- + + +class TestCamofoxSnapshot: + def test_no_session_returns_error(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + result = json.loads(camofox_snapshot(task_id="no_such_task")) + assert result["success"] is False + assert "browser_navigate" in result["error"] + + @patch("tools.browser_camofox.requests.post") + @patch("tools.browser_camofox.requests.get") + def test_returns_snapshot(self, mock_get, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + # Create session + mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t3") + + # Return snapshot + mock_get.return_value = _mock_response(json_data={ + "snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]", + "refsCount": 2, + }) + result = json.loads(camofox_snapshot(task_id="t3")) + assert result["success"] is True + assert "[e1]" in result["snapshot"] + assert result["element_count"] == 2 + + +# --------------------------------------------------------------------------- +# Click / Type / Scroll / Back / Press +# --------------------------------------------------------------------------- + + +class TestCamofoxInteractions: + @patch("tools.browser_camofox.requests.post") + def test_click(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t4") + + mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"}) + result = json.loads(camofox_click("@e5", task_id="t4")) + assert result["success"] is True + assert result["clicked"] == "e5" + + @patch("tools.browser_camofox.requests.post") + def test_type(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t5") + + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_type("@e3", "hello world", task_id="t5")) + assert result["success"] is True + assert result["typed"] == "hello world" + + @patch("tools.browser_camofox.requests.post") + def test_scroll(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t6") + + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_scroll("down", task_id="t6")) + assert result["success"] is True + assert result["scrolled"] == "down" + + @patch("tools.browser_camofox.requests.post") + def test_back(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t7") + + mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"}) + result = json.loads(camofox_back(task_id="t7")) + assert result["success"] is True + + @patch("tools.browser_camofox.requests.post") + def test_press(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t8") + + mock_post.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_press("Enter", task_id="t8")) + assert result["success"] is True + assert result["pressed"] == "Enter" + + +# --------------------------------------------------------------------------- +# Close +# --------------------------------------------------------------------------- + + +class TestCamofoxClose: + @patch("tools.browser_camofox.requests.delete") + @patch("tools.browser_camofox.requests.post") + def test_close_session(self, mock_post, mock_delete, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t9") + + mock_delete.return_value = _mock_response(json_data={"ok": True}) + result = json.loads(camofox_close(task_id="t9")) + assert result["success"] is True + assert result["closed"] is True + + def test_close_nonexistent_session(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + result = json.loads(camofox_close(task_id="nonexistent")) + assert result["success"] is True + + +# --------------------------------------------------------------------------- +# Console (limited support) +# --------------------------------------------------------------------------- + + +class TestCamofoxConsole: + def test_console_returns_empty_with_note(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + result = json.loads(camofox_console(task_id="t_console")) + assert result["success"] is True + assert result["total_messages"] == 0 + assert "not available" in result["note"] + + +# --------------------------------------------------------------------------- +# Images +# --------------------------------------------------------------------------- + + +class TestCamofoxGetImages: + @patch("tools.browser_camofox.requests.post") + @patch("tools.browser_camofox.requests.get") + def test_get_images(self, mock_get, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t10") + + mock_get.return_value = _mock_response(json_data={ + "images": [{"src": "https://x.com/img.png", "alt": "Logo"}], + }) + result = json.loads(camofox_get_images(task_id="t10")) + assert result["success"] is True + assert result["count"] == 1 + assert result["images"][0]["src"] == "https://x.com/img.png" + + +# --------------------------------------------------------------------------- +# Routing integration — verify browser_tool routes to camofox +# --------------------------------------------------------------------------- + + +class TestBrowserToolRouting: + """Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set.""" + + @patch("tools.browser_camofox.requests.post") + def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"}) + + from tools.browser_tool import browser_navigate + # Bypass SSRF check for test URL + with patch("tools.browser_tool._is_safe_url", return_value=True): + result = json.loads(browser_navigate("https://example.com", task_id="t_route")) + assert result["success"] is True + + def test_check_requirements_passes_with_camofox(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + from tools.browser_tool import check_browser_requirements + assert check_browser_requirements() is True + + +# --------------------------------------------------------------------------- +# Cleanup helper +# --------------------------------------------------------------------------- + + +class TestCamofoxCleanup: + @patch("tools.browser_camofox.requests.post") + @patch("tools.browser_camofox.requests.delete") + def test_cleanup_all(self, mock_delete, mock_post, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + mock_post.return_value = _mock_response(json_data={"tabId": "tab_c", "url": "https://x.com"}) + camofox_navigate("https://x.com", task_id="t_cleanup") + + mock_delete.return_value = _mock_response(json_data={"ok": True}) + cleanup_all_camofox_sessions() + + # Session should be gone + result = json.loads(camofox_snapshot(task_id="t_cleanup")) + assert result["success"] is False diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py new file mode 100644 index 0000000000..b1925d2c62 --- /dev/null +++ b/tools/browser_camofox.py @@ -0,0 +1,496 @@ +"""Camofox browser backend — local anti-detection browser via REST API. + +Camofox-browser is a self-hosted Node.js server wrapping Camoufox (Firefox +fork with C++ fingerprint spoofing). It exposes a REST API that maps 1:1 +to our browser tool interface: accessibility snapshots with element refs, +click/type/scroll by ref, screenshots, etc. + +When ``CAMOFOX_URL`` is set (e.g. ``http://localhost:9377``), the browser +tools route through this module instead of the ``agent-browser`` CLI. + +Setup:: + + # Option 1: npm + git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser + npm install && npm start # downloads Camoufox (~300MB) on first run + + # Option 2: Docker + docker run -p 9377:9377 jo-inc/camofox-browser + +Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Optional + +import requests + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +_DEFAULT_TIMEOUT = 30 # seconds per HTTP request +_SNAPSHOT_MAX_CHARS = 80_000 # camofox paginates at this limit + + +def get_camofox_url() -> str: + """Return the configured Camofox server URL, or empty string.""" + return os.getenv("CAMOFOX_URL", "").rstrip("/") + + +def is_camofox_mode() -> bool: + """True when Camofox backend is configured.""" + return bool(get_camofox_url()) + + +def check_camofox_available() -> bool: + """Verify the Camofox server is reachable.""" + url = get_camofox_url() + if not url: + return False + try: + resp = requests.get(f"{url}/health", timeout=5) + return resp.status_code == 200 + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Session management +# --------------------------------------------------------------------------- +# Maps task_id -> {"user_id": str, "tab_id": str|None} +_sessions: Dict[str, Dict[str, Any]] = {} +_sessions_lock = threading.Lock() + + +def _get_session(task_id: Optional[str]) -> Dict[str, Any]: + """Get or create a camofox session for the given task.""" + task_id = task_id or "default" + with _sessions_lock: + if task_id in _sessions: + return _sessions[task_id] + session = { + "user_id": f"hermes_{uuid.uuid4().hex[:10]}", + "tab_id": None, + "session_key": f"task_{task_id[:16]}", + } + _sessions[task_id] = session + return session + + +def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]: + """Ensure a tab exists for the session, creating one if needed.""" + session = _get_session(task_id) + if session["tab_id"]: + return session + base = get_camofox_url() + resp = requests.post( + f"{base}/tabs", + json={ + "userId": session["user_id"], + "sessionKey": session["session_key"], + "url": url, + }, + timeout=_DEFAULT_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + session["tab_id"] = data.get("tabId") + return session + + +def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]: + """Remove and return session info.""" + task_id = task_id or "default" + with _sessions_lock: + return _sessions.pop(task_id, None) + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _post(path: str, body: dict, timeout: int = _DEFAULT_TIMEOUT) -> dict: + """POST JSON to camofox and return parsed response.""" + url = f"{get_camofox_url()}{path}" + resp = requests.post(url, json=body, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +def _get(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict: + """GET from camofox and return parsed response.""" + url = f"{get_camofox_url()}{path}" + resp = requests.get(url, params=params, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +def _get_raw(path: str, params: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> requests.Response: + """GET from camofox and return raw response (for binary data).""" + url = f"{get_camofox_url()}{path}" + resp = requests.get(url, params=params, timeout=timeout) + resp.raise_for_status() + return resp + + +def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> dict: + """DELETE to camofox and return parsed response.""" + url = f"{get_camofox_url()}{path}" + resp = requests.delete(url, json=body, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + +def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: + """Navigate to a URL via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + # Create tab with the target URL directly + session = _ensure_tab(task_id, url) + data = {"ok": True, "url": url} + else: + # Navigate existing tab + data = _post( + f"/tabs/{session['tab_id']}/navigate", + {"userId": session["user_id"], "url": url}, + timeout=60, + ) + return json.dumps({ + "success": True, + "url": data.get("url", url), + "title": data.get("title", ""), + }) + except requests.HTTPError as e: + return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) + except requests.ConnectionError: + return json.dumps({ + "success": False, + "error": f"Cannot connect to Camofox at {get_camofox_url()}. " + "Is the server running? Start with: npm start (in camofox-browser dir) " + "or: docker run -p 9377:9377 jo-inc/camofox-browser", + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, + user_task: Optional[str] = None) -> str: + """Get accessibility tree snapshot from Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + + snapshot = data.get("snapshot", "") + refs_count = data.get("refsCount", 0) + + # Apply same summarization logic as the main browser tool + from tools.browser_tool import ( + SNAPSHOT_SUMMARIZE_THRESHOLD, + _extract_relevant_content, + _truncate_snapshot, + ) + + if len(snapshot) > SNAPSHOT_SUMMARIZE_THRESHOLD: + if user_task: + snapshot = _extract_relevant_content(snapshot, user_task) + else: + snapshot = _truncate_snapshot(snapshot) + + return json.dumps({ + "success": True, + "snapshot": snapshot, + "element_count": refs_count, + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_click(ref: str, task_id: Optional[str] = None) -> str: + """Click an element by ref via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + # Strip @ prefix if present (our tool convention) + clean_ref = ref.lstrip("@") + + data = _post( + f"/tabs/{session['tab_id']}/click", + {"userId": session["user_id"], "ref": clean_ref}, + ) + return json.dumps({ + "success": True, + "clicked": clean_ref, + "url": data.get("url", ""), + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: + """Type text into an element by ref via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + clean_ref = ref.lstrip("@") + + _post( + f"/tabs/{session['tab_id']}/type", + {"userId": session["user_id"], "ref": clean_ref, "text": text}, + ) + return json.dumps({ + "success": True, + "typed": text, + "element": clean_ref, + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str: + """Scroll the page via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + _post( + f"/tabs/{session['tab_id']}/scroll", + {"userId": session["user_id"], "direction": direction}, + ) + return json.dumps({"success": True, "scrolled": direction}) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_back(task_id: Optional[str] = None) -> str: + """Navigate back via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + data = _post( + f"/tabs/{session['tab_id']}/back", + {"userId": session["user_id"]}, + ) + return json.dumps({"success": True, "url": data.get("url", "")}) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_press(key: str, task_id: Optional[str] = None) -> str: + """Press a keyboard key via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + _post( + f"/tabs/{session['tab_id']}/press", + {"userId": session["user_id"], "key": key}, + ) + return json.dumps({"success": True, "pressed": key}) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_close(task_id: Optional[str] = None) -> str: + """Close the browser session via Camofox.""" + try: + session = _drop_session(task_id) + if not session: + return json.dumps({"success": True, "closed": True}) + + _delete( + f"/sessions/{session['user_id']}", + ) + return json.dumps({"success": True, "closed": True}) + except Exception as e: + return json.dumps({"success": True, "closed": True, "warning": str(e)}) + + +def camofox_get_images(task_id: Optional[str] = None) -> str: + """Get images on the current page via Camofox. + + Extracts image information from the accessibility tree snapshot, + since Camofox does not expose a dedicated /images endpoint. + """ + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + import re + + data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + snapshot = data.get("snapshot", "") + + # Parse img elements from the accessibility tree. + # Format: img "alt text" or img "alt text" [eN] + # URLs appear on /url: lines following img entries + images = [] + lines = snapshot.split("\n") + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("- img ") or stripped.startswith("img "): + alt_match = re.search(r'img\s+"([^"]*)"', stripped) + alt = alt_match.group(1) if alt_match else "" + # Look for URL on the next line + src = "" + if i + 1 < len(lines): + url_match = re.search(r'/url:\s*(\S+)', lines[i + 1].strip()) + if url_match: + src = url_match.group(1) + if alt or src: + images.append({"src": src, "alt": alt}) + + return json.dumps({ + "success": True, + "images": images, + "count": len(images), + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_vision(question: str, annotate: bool = False, + task_id: Optional[str] = None) -> str: + """Take a screenshot and analyze it with vision AI via Camofox.""" + try: + session = _get_session(task_id) + if not session["tab_id"]: + return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + + # Get screenshot as binary PNG + resp = _get_raw( + f"/tabs/{session['tab_id']}/screenshot", + params={"userId": session["user_id"]}, + ) + + # Save screenshot to cache + from hermes_constants import get_hermes_home + screenshots_dir = get_hermes_home() / "browser_screenshots" + screenshots_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = str(screenshots_dir / f"browser_screenshot_{uuid.uuid4().hex[:8]}.png") + + with open(screenshot_path, "wb") as f: + f.write(resp.content) + + # Encode for vision LLM + img_b64 = base64.b64encode(resp.content).decode("utf-8") + + # Also get annotated snapshot if requested + annotation_context = "" + if annotate: + try: + snap_data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snap_data.get('snapshot', '')[:3000]}" + except Exception: + pass + + # Send to vision LLM + from agent.auxiliary_client import call_llm + + vision_prompt = ( + f"Analyze this browser screenshot and answer: {question}" + f"{annotation_context}" + ) + + try: + from hermes_cli.config import load_config + _cfg = load_config() + _vision_timeout = int(_cfg.get("auxiliary", {}).get("vision", {}).get("timeout", 120)) + except Exception: + _vision_timeout = 120 + + analysis = call_llm( + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": vision_prompt}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{img_b64}", + }, + }, + ], + }], + task="vision", + timeout=_vision_timeout, + ) + + return json.dumps({ + "success": True, + "analysis": analysis, + "screenshot_path": screenshot_path, + }) + except Exception as e: + return json.dumps({"success": False, "error": str(e)}) + + +def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str: + """Get console output — limited support in Camofox. + + Camofox does not expose browser console logs via its REST API. + Returns an empty result with a note. + """ + return json.dumps({ + "success": True, + "console_messages": [], + "js_errors": [], + "total_messages": 0, + "total_errors": 0, + "note": "Console log capture is not available with the Camofox backend. " + "Use browser_snapshot or browser_vision to inspect page state.", + }) + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +def cleanup_all_camofox_sessions() -> None: + """Close all active camofox sessions.""" + with _sessions_lock: + sessions = list(_sessions.items()) + for task_id, session in sessions: + try: + _delete(f"/sessions/{session['user_id']}") + except Exception: + pass + with _sessions_lock: + _sessions.clear() diff --git a/tools/browser_tool.py b/tools/browser_tool.py index ffb772c1d6..33a1c8ef66 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -79,6 +79,14 @@ from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider +# Camofox local anti-detection browser backend (optional). +# When CAMOFOX_URL is set, all browser operations route through the +# camofox REST API instead of the agent-browser CLI. +try: + from tools.browser_camofox import is_camofox_mode as _is_camofox_mode +except ImportError: + _is_camofox_mode = lambda: False # noqa: E731 + logger = logging.getLogger(__name__) # Standard PATH entries for environments with minimal PATH (e.g. systemd services). @@ -1046,6 +1054,11 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: "blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}, }) + # Camofox backend — delegate after safety checks pass + if _is_camofox_mode(): + from tools.browser_camofox import camofox_navigate + return camofox_navigate(url, task_id) + effective_task_id = task_id or "default" # Get session info to check if this is a new session @@ -1135,6 +1148,10 @@ def browser_snapshot( Returns: JSON string with page snapshot """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_snapshot + return camofox_snapshot(full, task_id, user_task) + effective_task_id = task_id or "default" # Build command args based on full flag @@ -1180,6 +1197,10 @@ def browser_click(ref: str, task_id: Optional[str] = None) -> str: Returns: JSON string with click result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_click + return camofox_click(ref, task_id) + effective_task_id = task_id or "default" # Ensure ref starts with @ @@ -1212,6 +1233,10 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str: Returns: JSON string with type result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_type + return camofox_type(ref, text, task_id) + effective_task_id = task_id or "default" # Ensure ref starts with @ @@ -1245,6 +1270,10 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: Returns: JSON string with scroll result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_scroll + return camofox_scroll(direction, task_id) + effective_task_id = task_id or "default" # Validate direction @@ -1278,6 +1307,10 @@ def browser_back(task_id: Optional[str] = None) -> str: Returns: JSON string with navigation result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_back + return camofox_back(task_id) + effective_task_id = task_id or "default" result = _run_browser_command(effective_task_id, "back", []) @@ -1305,6 +1338,10 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str: Returns: JSON string with key press result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_press + return camofox_press(key, task_id) + effective_task_id = task_id or "default" result = _run_browser_command(effective_task_id, "press", [key]) @@ -1330,6 +1367,10 @@ def browser_close(task_id: Optional[str] = None) -> str: Returns: JSON string with close result """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_close + return camofox_close(task_id) + effective_task_id = task_id or "default" with _cleanup_lock: had_session = effective_task_id in _active_sessions @@ -1358,6 +1399,10 @@ def browser_console(clear: bool = False, task_id: Optional[str] = None) -> str: Returns: JSON string with console messages and JS errors """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_console + return camofox_console(clear, task_id) + effective_task_id = task_id or "default" console_args = ["--clear"] if clear else [] @@ -1452,6 +1497,10 @@ def browser_get_images(task_id: Optional[str] = None) -> str: Returns: JSON string with list of images (src and alt) """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_get_images + return camofox_get_images(task_id) + effective_task_id = task_id or "default" # Use eval to run JavaScript that extracts images @@ -1516,6 +1565,10 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] Returns: JSON string with vision analysis results and screenshot_path """ + if _is_camofox_mode(): + from tools.browser_camofox import camofox_vision + return camofox_vision(question, annotate, task_id) + import base64 import uuid as uuid_mod from pathlib import Path @@ -1804,6 +1857,10 @@ def check_browser_requirements() -> bool: Returns: True if all requirements are met, False otherwise """ + # Camofox backend — only needs the server URL, no agent-browser CLI + if _is_camofox_mode(): + return True + # The agent-browser CLI is always required try: _find_agent_browser() From 7b4fe0528f95ea7c64f2c7ff064f0f8d0ddaa5b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:19:44 -0700 Subject: [PATCH 42/52] fix(auth): use bearer auth for MiniMax Anthropic endpoints (#4028) MiniMax's /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of x-api-key. Without this fix, MiniMax users get 401 errors in gateway sessions. Adds _requires_bearer_auth() to detect MiniMax endpoints and route through auth_token in the Anthropic SDK. Check runs before OAuth token detection so MiniMax keys aren't misclassified as setup tokens. Co-authored-by: kshitijk4poor --- agent/anthropic_adapter.py | 27 ++++++++++++++++++++++++++- tests/test_anthropic_adapter.py | 13 +++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a2a052d0a8..a817364965 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -162,6 +162,21 @@ def _is_oauth_token(key: str) -> bool: return True +def _requires_bearer_auth(base_url: str | None) -> bool: + """Return True for Anthropic-compatible providers that require Bearer auth. + + Some third-party /anthropic endpoints implement Anthropic's Messages API but + require Authorization: Bearer instead of Anthropic's native x-api-key header. + MiniMax's global and China Anthropic-compatible endpoints follow this pattern. + """ + if not base_url: + return False + normalized = base_url.rstrip("/").lower() + return normalized.startswith("https://api.minimax.io/anthropic") or normalized.startswith( + "https://api.minimaxi.com/anthropic" + ) + + def build_anthropic_client(api_key: str, base_url: str = None): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. @@ -180,7 +195,17 @@ def build_anthropic_client(api_key: str, base_url: str = None): if base_url: kwargs["base_url"] = base_url - if _is_oauth_token(api_key): + if _requires_bearer_auth(base_url): + # Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in + # Authorization: Bearer even for regular API keys. Route those endpoints + # through auth_token so the SDK sends Bearer auth instead of x-api-key. + # Check this before OAuth token shape detection because MiniMax secrets do + # not use Anthropic's sk-ant-api prefix and would otherwise be misread as + # Anthropic OAuth/setup tokens. + kwargs["auth_token"] = api_key + if _COMMON_BETAS: + kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)} + elif _is_oauth_token(api_key): # OAuth access token / setup-token → Bearer auth + Claude Code identity. # Anthropic routes OAuth requests based on user-agent and headers; # without Claude Code's fingerprint, requests get intermittent 500s. diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 7e2e1c767e..4b4669eabc 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -81,6 +81,19 @@ class TestBuildAnthropicClient: kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["base_url"] == "https://custom.api.com" + def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "minimax-secret-123", + base_url="https://api.minimax.io/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["auth_token"] == "minimax-secret-123" + assert "api_key" not in kwargs + assert kwargs["default_headers"] == { + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + } + class TestReadClaudeCodeCredentials: def test_reads_valid_credentials(self, tmp_path, monkeypatch): From f93637b3a16bc5a638eabd007ad7f27eaebf71fe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:20:06 -0700 Subject: [PATCH 43/52] feat: add /profile slash command to show active profile (#4027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /profile to COMMAND_REGISTRY (Info category) with handlers in both CLI and gateway. Shows the active profile name and home directory. Works on all platforms — CLI, Telegram, Discord, Slack, etc. Detects profile by checking if HERMES_HOME is under ~/.hermes/profiles/. Shows 'default' when running without a profile. --- cli.py | 24 ++++++++++++++++++++++++ gateway/run.py | 33 +++++++++++++++++++++++++++++++++ hermes_cli/commands.py | 1 + 3 files changed, 58 insertions(+) diff --git a/cli.py b/cli.py index 223c40563d..e01a0e797d 100644 --- a/cli.py +++ b/cli.py @@ -2837,6 +2837,28 @@ class HermesCLI: print(" Example: python cli.py --toolsets web,terminal") print() + def _handle_profile_command(self): + """Display active profile name and home directory.""" + from hermes_constants import get_hermes_home, display_hermes_home + + home = get_hermes_home() + display = display_hermes_home() + + profiles_parent = Path.home() / ".hermes" / "profiles" + try: + rel = home.relative_to(profiles_parent) + profile_name = str(rel).split("/")[0] + except ValueError: + profile_name = None + + print() + if profile_name: + print(f" Profile: {profile_name}") + else: + print(" Profile: default") + print(f" Home: {display}") + print() + def show_config(self): """Display current configuration with kawaii ASCII art.""" # Get terminal config from environment (which was set from cli-config.yaml) @@ -3679,6 +3701,8 @@ class HermesCLI: return False elif canonical == "help": self.show_help() + elif canonical == "profile": + self._handle_profile_command() elif canonical == "tools": self._handle_tools_command(cmd_original) elif canonical == "toolsets": diff --git a/gateway/run.py b/gateway/run.py index c85ed27b88..7638d8a518 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1872,6 +1872,9 @@ class GatewayRunner: if canonical == "commands": return await self._handle_commands_command(event) + if canonical == "profile": + return await self._handle_profile_command(event) + if canonical == "status": return await self._handle_status_command(event) @@ -3070,6 +3073,36 @@ class GatewayRunner: return f"{header}\n\n{session_info}" return header + async def _handle_profile_command(self, event: MessageEvent) -> str: + """Handle /profile — show active profile name and home directory.""" + from hermes_constants import get_hermes_home, display_hermes_home + from pathlib import Path + + home = get_hermes_home() + display = display_hermes_home() + + # Detect profile name from HERMES_HOME path + # Profile paths look like: ~/.hermes/profiles/ + profiles_parent = Path.home() / ".hermes" / "profiles" + try: + rel = home.relative_to(profiles_parent) + profile_name = str(rel).split("/")[0] + except ValueError: + profile_name = None + + if profile_name: + lines = [ + f"👤 **Profile:** `{profile_name}`", + f"📂 **Home:** `{display}`", + ] + else: + lines = [ + "👤 **Profile:** default", + f"📂 **Home:** `{display}`", + ] + + return "\n".join(lines) + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3b1eb37ff5..d9de67175d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -71,6 +71,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("q",), args_hint=""), CommandDef("status", "Show session info", "Session", gateway_only=True), + CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), CommandDef("resume", "Resume a previously-named session", "Session", From bd376fe97604f3fafd16052815d539d0f898ef0f Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 30 Mar 2026 13:20:55 -0700 Subject: [PATCH 44/52] fix(docs): improve mobile sidebar navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar had all categories expanded by default (collapsed: false), which on mobile created a 60+ item flat list when opening the sidebar. Reported by danny on Discord. Changes: - Set all top-level categories to collapsed: true (tap to expand) - Enable autoCollapseCategories: true (accordion — opening one section closes others, prevents the overwhelming flat list) - Enable hideable sidebar (swipe-to-dismiss on mobile) - Add mobile CSS: larger touch targets (0.75rem padding), bolder category headers, visible subcategory indentation with left border, wider sidebar (85vw / 360px max), darker backdrop overlay --- website/docusaurus.config.ts | 6 ++++++ website/sidebars.ts | 6 +++--- website/src/css/custom.css | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 6d8b52bfe8..bbd7d4ea98 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -65,6 +65,12 @@ const config: Config = { defaultMode: 'dark', respectPrefersColorScheme: true, }, + docs: { + sidebar: { + hideable: true, + autoCollapseCategories: true, + }, + }, navbar: { title: 'Hermes Agent', logo: { diff --git a/website/sidebars.ts b/website/sidebars.ts index 082b9ce8fd..4c7bfc2e2f 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -5,7 +5,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Getting Started', - collapsed: false, + collapsed: true, items: [ 'getting-started/quickstart', 'getting-started/installation', @@ -17,7 +17,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Guides & Tutorials', - collapsed: false, + collapsed: true, items: [ 'guides/tips', 'guides/daily-briefing-bot', @@ -32,7 +32,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'User Guide', - collapsed: false, + collapsed: true, items: [ 'user-guide/cli', 'user-guide/configuration', diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 1df449986d..7c70003917 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -199,6 +199,46 @@ pre.prism-code.language-ascii code { border: 1px solid rgba(255, 215, 0, 0.08); } +/* ─── Mobile sidebar improvements ─────────────────────────────────────────── */ + +/* Larger touch targets on mobile */ +@media (max-width: 996px) { + .menu__link { + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + } + + .menu__list-item-collapsible > .menu__link { + font-weight: 600; + font-size: 1rem; + padding: 0.75rem 0.75rem; + border-bottom: 1px solid rgba(255, 215, 0, 0.06); + } + + /* Category caret — more visible */ + .menu__caret::before { + background-size: 1.5rem 1.5rem; + } + + /* Indent subcategories clearly */ + .menu__list .menu__list { + padding-left: 0.75rem; + border-left: 1px solid rgba(255, 215, 0, 0.06); + margin-left: 0.5rem; + } + + /* Sidebar overlay — slightly more opaque for readability */ + .navbar-sidebar__backdrop { + background-color: rgba(0, 0, 0, 0.6); + } + + /* Sidebar width on mobile — use more of the screen */ + .navbar-sidebar { + width: 85vw; + max-width: 360px; + } +} + /* Hero banner for docs landing if needed */ .hero--hermes { background: linear-gradient(135deg, #07070d 0%, #0f0f18 100%); From 4b35836ba42a59a669699197573a969431b4df44 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:21:39 -0700 Subject: [PATCH 45/52] fix(auth): use bearer auth for MiniMax Anthropic endpoints (#4028) MiniMax's /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of x-api-key. Without this fix, MiniMax users get 401 errors in gateway sessions. Adds _requires_bearer_auth() to detect MiniMax endpoints and route through auth_token in the Anthropic SDK. Check runs before OAuth token detection so MiniMax keys aren't misclassified as setup tokens. Co-authored-by: kshitijk4poor From 72104eb06f267286ec207feed65dc00656ce4e9f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:24:48 -0700 Subject: [PATCH 46/52] fix(gateway): honor default for invalid bool-like config values (#4029) Co-authored-by: aydnOktay --- gateway/config.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index c8ce89a7d6..8c7843780a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -27,9 +27,16 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: return default if isinstance(value, bool): return value + if isinstance(value, int): + return value != 0 if isinstance(value, str): - return value.strip().lower() in ("true", "1", "yes", "on") - return bool(value) + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "on"): + return True + if lowered in ("false", "0", "no", "off"): + return False + return default + return default def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: From eba8d52d541282c18f853ba9f56a615276097096 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:25:11 -0700 Subject: [PATCH 47/52] fix: show correct shell config path for macOS/zsh in install script (#4025) - print_success() hardcoded 'source ~/.bashrc' regardless of user's shell - On macOS (default zsh), ~/.bashrc doesn't exist, leaving users unable to find the hermes command after install - Now detects $SHELL and shows the correct file (zshrc/bashrc) - Also captures .[all] install failure output instead of silencing with 2>/dev/null, so users can diagnose why full extras failed --- scripts/install.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index d46771e6aa..c04dc4a9d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -699,14 +699,19 @@ install_deps() { # Install the main package in editable mode with all extras. # Try [all] first, fall back to base install if extras have issues. - if ! $UV_CMD pip install -e ".[all]" 2>/dev/null; then + ALL_INSTALL_LOG=$(mktemp) + if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then log_warn "Full install (.[all]) failed, trying base install..." + log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)" + rm -f "$ALL_INSTALL_LOG" if ! $UV_CMD pip install -e "."; then log_error "Package installation failed." log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" exit 1 fi + else + rm -f "$ALL_INSTALL_LOG" fi log_success "Main package installed" @@ -1070,7 +1075,14 @@ print_success() { echo "" echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}" echo "" - echo " source ~/.bashrc # or ~/.zshrc" + LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")" + if [ "$LOGIN_SHELL" = "zsh" ]; then + echo " source ~/.zshrc" + elif [ "$LOGIN_SHELL" = "bash" ]; then + echo " source ~/.bashrc" + else + echo " source ~/.bashrc # or ~/.zshrc" + fi echo "" # Show Node.js warning if auto-install failed From 0d1003559d85372aed77116a68362e73e93b5b37 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:37:25 -0700 Subject: [PATCH 48/52] refactor: simplify web backend priority detection (#4036) * fix(gateway): honor default for invalid bool-like config values * refactor: simplify web backend priority detection Replace cascading boolean conditions with a priority-ordered loop. Same behavior (verified against all 16 env var combinations), half the lines, trivially extensible for new backends. --------- Co-authored-by: aydnOktay --- tools/web_tools.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tools/web_tools.py b/tools/web_tools.py index c8e7fb0f36..c61bc1eb7c 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -77,20 +77,18 @@ def _get_backend() -> str: if configured in ("parallel", "firecrawl", "tavily", "exa"): return configured - # Fallback for manual / legacy config — use whichever key is present. - has_firecrawl = _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") - has_parallel = _has_env("PARALLEL_API_KEY") - has_tavily = _has_env("TAVILY_API_KEY") - has_exa = _has_env("EXA_API_KEY") - if has_exa and not has_firecrawl and not has_parallel and not has_tavily: - return "exa" - if has_tavily and not has_firecrawl and not has_parallel: - return "tavily" - if has_parallel and not has_firecrawl: - return "parallel" + # Fallback for manual / legacy config — pick highest-priority backend + # that has a key configured. Order: firecrawl > parallel > tavily > exa. + for backend, keys in [ + ("firecrawl", ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL")), + ("parallel", ("PARALLEL_API_KEY",)), + ("tavily", ("TAVILY_API_KEY",)), + ("exa", ("EXA_API_KEY",)), + ]: + if any(_has_env(k) for k in keys): + return backend - # Default to firecrawl (backward compat, or when both are set) - return "firecrawl" + return "firecrawl" # default (backward compat) # ─── Firecrawl Client ──────────────────────────────────────────────────────── From de368cac54eba1be7e58ff260f332d500ccbda76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:11:39 -0700 Subject: [PATCH 49/52] fix(tools): show browser and TTS in reconfigure menu (#4041) * fix(gateway): honor default for invalid bool-like config values * refactor: simplify web backend priority detection Replace cascading boolean conditions with a priority-ordered loop. Same behavior (verified against all 16 env var combinations), half the lines, trivially extensible for new backends. * fix(tools): show browser and TTS in reconfigure menu _toolset_has_keys() returned False for toolsets with no-key providers (Local Browser, Edge TTS) because it only checked providers with env_vars. Users couldn't find these tools in the reconfigure list and had no obvious way to switch browser/TTS backends. Now treats providers with empty env_vars as always-configured, so toolsets with free/local options always appear in the reconfigure menu. --------- Co-authored-by: aydnOktay --- hermes_cli/tools_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 63e26d3627..337b67fe8f 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -597,7 +597,9 @@ def _toolset_has_keys(ts_key: str) -> bool: if cat: for provider in cat.get("providers", []): env_vars = provider.get("env_vars", []) - if env_vars and all(get_env_value(e["key"]) for e in env_vars): + if not env_vars: + return True # No-key provider (e.g. Local Browser, Edge TTS) + if all(get_env_value(e["key"]) for e in env_vars): return True return False From ab62614a89c568dfb10f78368570b36308a0b758 Mon Sep 17 00:00:00 2001 From: SHL0MS Date: Mon, 30 Mar 2026 18:48:22 -0400 Subject: [PATCH 50/52] ascii-video: add text readability techniques and external layout oracle pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - composition.md: add text backdrop (gaussian dark mask behind glyphs) and external layout oracle pattern (browser-based text layout → JSON → Python renderer pipeline for obstacle-aware text reflow) - shaders.md: add reverse vignette shader (center-darkening for text readability) - troubleshooting.md: add diagnostic entries for text-over-busy-background readability and kaleidoscope-destroys-text pitfall --- .../ascii-video/references/composition.md | 146 ++++++++++++++++++ .../ascii-video/references/shaders.md | 33 ++++ .../ascii-video/references/troubleshooting.md | 2 + 3 files changed, 181 insertions(+) diff --git a/skills/creative/ascii-video/references/composition.md b/skills/creative/ascii-video/references/composition.md index 0028b93fa2..f7e6eff899 100644 --- a/skills/creative/ascii-video/references/composition.md +++ b/skills/creative/ascii-video/references/composition.md @@ -744,3 +744,149 @@ class PixelBlendStack: result = blend_canvas(result, canvas, mode, opacity) return result ``` + +## Text Backdrop (Readability Mask) + +When placing readable text over busy multi-grid ASCII backgrounds, the text will blend into the background and become illegible. **Always apply a dark backdrop behind text regions.** + +The technique: compute the bounding box of all text glyphs, create a gaussian-blurred dark mask covering that area with padding, and multiply the background by `(1 - mask * darkness)` before rendering text on top. + +```python +from scipy.ndimage import gaussian_filter + +def apply_text_backdrop(canvas, glyphs, padding=80, darkness=0.75): + """Darken the background behind text for readability. + + Call AFTER rendering background, BEFORE rendering text. + + Args: + canvas: (VH, VW, 3) uint8 background + glyphs: list of {"x": float, "y": float, ...} glyph positions + padding: pixel padding around text bounding box + darkness: 0.0 = no darkening, 1.0 = fully black + Returns: + darkened canvas (uint8) + """ + if not glyphs: + return canvas + xs = [g['x'] for g in glyphs] + ys = [g['y'] for g in glyphs] + x0 = max(0, int(min(xs)) - padding) + y0 = max(0, int(min(ys)) - padding) + x1 = min(VW, int(max(xs)) + padding + 50) # extra for char width + y1 = min(VH, int(max(ys)) + padding + 60) # extra for char height + + # Soft dark mask with gaussian blur for feathered edges + mask = np.zeros((VH, VW), dtype=np.float32) + mask[y0:y1, x0:x1] = 1.0 + mask = gaussian_filter(mask, sigma=padding * 0.6) + + factor = 1.0 - mask * darkness + return (canvas.astype(np.float32) * factor[:, :, np.newaxis]).astype(np.uint8) +``` + +### Usage in render pipeline + +Insert between background rendering and text rendering: + +```python +# 1. Render background (multi-grid ASCII effects) +bg = render_background(cfg, t) + +# 2. Darken behind text region +bg = apply_text_backdrop(bg, frame_glyphs, padding=80, darkness=0.75) + +# 3. Render text on top (now readable against dark backdrop) +bg = text_renderer.render(bg, frame_glyphs, color=(255, 255, 255)) +``` + +Combine with **reverse vignette** (see shaders.md) for scenes where text is always centered — the reverse vignette provides a persistent center-dark zone, while the backdrop handles per-frame glyph positions. + +## External Layout Oracle Pattern + +For text-heavy videos where text needs to dynamically reflow around obstacles (shapes, icons, other text), use an external layout engine to pre-compute glyph positions and feed them into the Python renderer via JSON. + +### Architecture + +``` +Layout Engine (browser/Node.js) → layouts.json → Python ASCII Renderer + ↑ ↑ + Computes per-frame Reads glyph positions, + glyph (x,y) positions renders as ASCII chars + with obstacle-aware reflow with full effect pipeline +``` + +### JSON interchange format + +```json +{ + "meta": { + "canvas_width": 1080, "canvas_height": 1080, + "fps": 24, "total_frames": 1248, + "fonts": { + "body": {"charW": 12.04, "charH": 24, "fontSize": 20}, + "hero": {"charW": 24.08, "charH": 48, "fontSize": 40} + } + }, + "scenes": [ + { + "id": "scene_name", + "start_frame": 0, "end_frame": 96, + "frames": { + "0": { + "glyphs": [ + {"char": "H", "x": 287.1, "y": 400.0, "alpha": 1.0}, + {"char": "e", "x": 311.2, "y": 400.0, "alpha": 1.0} + ], + "obstacles": [ + {"type": "circle", "cx": 540, "cy": 540, "r": 80}, + {"type": "rect", "x": 300, "y": 500, "w": 120, "h": 80} + ] + } + } + } + ] +} +``` + +### When to use + +- Text that dynamically reflows around moving objects +- Per-glyph animation (reveal, scatter, physics) +- Variable typography that needs precise measurement +- Any case where Python's Pillow text layout is insufficient + +### When NOT to use + +- Static centered text (just use PIL `draw.text()` directly) +- Text that only fades in/out without spatial animation +- Simple typewriter effects (handle in Python with a character counter) + +### Running the oracle + +Use Playwright to run the layout engine in a headless browser: + +```javascript +// extract.mjs +import { chromium } from 'playwright'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto(`file://${oraclePath}`); +await page.waitForFunction(() => window.__ORACLE_DONE__ === true, null, { timeout: 60000 }); +const result = await page.evaluate(() => window.__ORACLE_RESULT__); +writeFileSync('layouts.json', JSON.stringify(result)); +await browser.close(); +``` + +### Consuming in Python + +```python +# In the renderer, map pixel positions to the canvas: +for glyph in frame_data['glyphs']: + char, px, py = glyph['char'], glyph['x'], glyph['y'] + alpha = glyph.get('alpha', 1.0) + # Render using PIL draw.text() at exact pixel position + draw.text((px, py), char, fill=(int(255*alpha),)*3, font=font) +``` + +Obstacles from the JSON can also be rendered as glowing ASCII shapes (circles, rectangles) to visualize the reflow zones. diff --git a/skills/creative/ascii-video/references/shaders.md b/skills/creative/ascii-video/references/shaders.md index fce436a4d8..a4cf7a2e5d 100644 --- a/skills/creative/ascii-video/references/shaders.md +++ b/skills/creative/ascii-video/references/shaders.md @@ -834,6 +834,39 @@ def sh_vignette(c, s=0.22): return np.clip(c * _vig_cache[k][:,:,None], 0, 255).astype(np.uint8) ``` +#### Reverse Vignette + +Inverted vignette: darkens the **center** and leaves edges bright. Useful when text is centered over busy backgrounds — creates a natural dark zone for readability without a hard-edged box. + +Combine with `apply_text_backdrop()` (see composition.md) for per-frame glyph-aware darkening. + +```python +_rvignette_cache = {} + +def sh_reverse_vignette(c, strength=0.5): + """Center darkening, edge brightening. Cached.""" + k = ('rv', c.shape[0], c.shape[1], round(strength, 2)) + if k not in _rvignette_cache: + h, w = c.shape[:2] + Y = np.linspace(-1, 1, h)[:, None] + X = np.linspace(-1, 1, w)[None, :] + d = np.sqrt(X**2 + Y**2) + # Invert: bright at edges, dark at center + mask = np.clip(1.0 - (1.0 - d * 0.7) * strength, 0.2, 1.0) + _rvignette_cache[k] = mask[:, :, np.newaxis].astype(np.float32) + return np.clip(c.astype(np.float32) * _rvignette_cache[k], 0, 255).astype(np.uint8) +``` + +| Param | Default | Effect | +|-------|---------|--------| +| `strength` | 0.5 | 0 = no effect, 1.0 = center nearly black | + +Add to ShaderChain dispatch: +```python +elif name == "reverse_vignette": + return sh_reverse_vignette(canvas, kwargs.get("strength", 0.5)) +``` + #### Contrast ```python def sh_contrast(c, factor=1.3): diff --git a/skills/creative/ascii-video/references/troubleshooting.md b/skills/creative/ascii-video/references/troubleshooting.md index 8c4bb02293..6b38382cd6 100644 --- a/skills/creative/ascii-video/references/troubleshooting.md +++ b/skills/creative/ascii-video/references/troubleshooting.md @@ -14,6 +14,8 @@ | Random dark holes in output | Font missing Unicode glyphs | Validate palettes at init | | Audio-visual desync | Frame timing accumulation | Use integer frame counter, compute t fresh each frame | | Single-color flat output | Hue field shape mismatch | Ensure h,s,v arrays all (rows,cols) before hsv2rgb | +| Text unreadable over busy bg | No contrast between text and background | Use `apply_text_backdrop()` (composition.md) + `reverse_vignette` shader (shaders.md) | +| Text garbled/mirrored | Kaleidoscope or mirror shader applied to text scene | **Never apply kaleidoscope, mirror_h/v/quad/diag to scenes with readable text** — radial folding destroys legibility. Apply these only to background layers or text-free scenes | Common bugs, gotchas, and platform-specific issues encountered during ASCII video development. From 3d47af01c3b7e348fe5fb7340412fd081b7eab19 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:41:19 -0700 Subject: [PATCH 51/52] fix(honcho): write config to instance-local path for profile isolation (#4037) Multiple agents/profiles running 'hermes honcho setup' all wrote to the shared global ~/.honcho/config.json, overwriting each other's configuration. Root cause: _write_config() defaulted to resolve_config_path() which returns the global path when no instance-local file exists yet (i.e. on first setup). Fix: _write_config() now defaults to _local_config_path() which always returns $HERMES_HOME/honcho.json. Each profile gets its own config file. Reading still falls back to global for cross-app interop and seeding. Also updates cmd_setup and cmd_status messaging to show the actual write path. Includes 10 new tests verifying profile isolation, global fallback reads, and multi-profile independence. --- honcho_integration/cli.py | 30 ++- .../test_config_isolation.py | 190 ++++++++++++++++++ 2 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 tests/honcho_integration/test_config_isolation.py diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index ae09c37134..f6cbcedf66 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -10,16 +10,27 @@ import os import sys from pathlib import Path +from hermes_constants import get_hermes_home from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH HOST = "hermes" def _config_path() -> Path: - """Return the active Honcho config path (instance-local or global).""" + """Return the active Honcho config path for reading (instance-local or global).""" return resolve_config_path() +def _local_config_path() -> Path: + """Return the instance-local Honcho config path for writing. + + Always returns $HERMES_HOME/honcho.json so each profile/instance gets + its own config file. The global ~/.honcho/config.json is only used as + a read fallback (via resolve_config_path) for cross-app interop. + """ + return get_hermes_home() / "honcho.json" + + def _read_config() -> dict: path = _config_path() if path.exists(): @@ -31,7 +42,7 @@ def _read_config() -> dict: def _write_config(cfg: dict, path: Path | None = None) -> None: - path = path or _config_path() + path = path or _local_config_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", @@ -95,13 +106,13 @@ def cmd_setup(args) -> None: """Interactive Honcho setup wizard.""" cfg = _read_config() - active_path = _config_path() + write_path = _local_config_path() + read_path = _config_path() print("\nHoncho memory setup\n" + "─" * 40) print(" Honcho gives Hermes persistent cross-session memory.") - if active_path != GLOBAL_CONFIG_PATH: - print(f" Instance config: {active_path}") - else: - print(" Config is shared with other hosts at ~/.honcho/config.json") + print(f" Config: {write_path}") + if read_path != write_path and read_path.exists(): + print(f" (seeding from existing config at {read_path})") print() if not _ensure_sdk_installed(): @@ -189,7 +200,7 @@ def cmd_setup(args) -> None: hermes_host.setdefault("saveMessages", True) _write_config(cfg) - print(f"\n Config written to {active_path}") + print(f"\n Config written to {write_path}") # Test connection print(" Testing connection... ", end="", flush=True) @@ -237,6 +248,7 @@ def cmd_status(args) -> None: cfg = _read_config() active_path = _config_path() + write_path = _local_config_path() if not cfg: print(f" No Honcho config found at {active_path}") @@ -259,6 +271,8 @@ def cmd_status(args) -> None: print(f" Workspace: {hcfg.workspace_id}") print(f" Host: {hcfg.host}") print(f" Config path: {active_path}") + if write_path != active_path: + print(f" Write path: {write_path} (instance-local)") print(f" AI peer: {hcfg.ai_peer}") print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") diff --git a/tests/honcho_integration/test_config_isolation.py b/tests/honcho_integration/test_config_isolation.py new file mode 100644 index 0000000000..4d9898e681 --- /dev/null +++ b/tests/honcho_integration/test_config_isolation.py @@ -0,0 +1,190 @@ +"""Tests for Honcho config profile isolation. + +Verifies that each Hermes profile writes to its own instance-local +honcho.json ($HERMES_HOME/honcho.json) rather than the shared global +~/.honcho/config.json. +""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from honcho_integration.cli import ( + _config_path, + _local_config_path, + _read_config, + _write_config, +) + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Create an isolated HERMES_HOME + real home for testing.""" + hermes_home = tmp_path / "profile_a" + hermes_home.mkdir() + global_dir = tmp_path / "home" / ".honcho" + global_dir.mkdir(parents=True) + global_config = global_dir / "config.json" + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home")) + # GLOBAL_CONFIG_PATH is a module-level constant cached at import time, + # so we must patch it in both the defining module and the importing module. + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config) + + return { + "hermes_home": hermes_home, + "global_config": global_config, + "local_config": hermes_home / "honcho.json", + } + + +class TestLocalConfigPath: + """_local_config_path always returns $HERMES_HOME/honcho.json.""" + + def test_returns_hermes_home_path(self, isolated_home): + assert _local_config_path() == isolated_home["local_config"] + + def test_differs_from_global(self, isolated_home): + from honcho_integration.client import GLOBAL_CONFIG_PATH + assert _local_config_path() != GLOBAL_CONFIG_PATH + + +class TestWriteConfigIsolation: + """_write_config defaults to the instance-local path.""" + + def test_write_creates_local_file(self, isolated_home): + cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}} + _write_config(cfg) + + assert isolated_home["local_config"].exists() + written = json.loads(isolated_home["local_config"].read_text()) + assert written["apiKey"] == "test-key" + + def test_write_does_not_touch_global(self, isolated_home): + # Pre-populate global config + isolated_home["global_config"].write_text( + json.dumps({"apiKey": "global-key"}) + ) + + cfg = {"apiKey": "profile-key"} + _write_config(cfg) + + # Global should be untouched + global_data = json.loads(isolated_home["global_config"].read_text()) + assert global_data["apiKey"] == "global-key" + + # Local should have the new value + local_data = json.loads(isolated_home["local_config"].read_text()) + assert local_data["apiKey"] == "profile-key" + + def test_explicit_path_override_still_works(self, isolated_home): + custom = isolated_home["hermes_home"] / "custom.json" + _write_config({"custom": True}, path=custom) + assert custom.exists() + assert not isolated_home["local_config"].exists() + + +class TestReadConfigFallback: + """_read_config falls back to global when no local file exists.""" + + def test_reads_local_when_exists(self, isolated_home): + isolated_home["local_config"].write_text( + json.dumps({"source": "local"}) + ) + cfg = _read_config() + assert cfg["source"] == "local" + + def test_falls_back_to_global(self, isolated_home): + isolated_home["global_config"].write_text( + json.dumps({"source": "global"}) + ) + # No local file exists + assert not isolated_home["local_config"].exists() + cfg = _read_config() + assert cfg["source"] == "global" + + def test_local_takes_priority_over_global(self, isolated_home): + isolated_home["local_config"].write_text( + json.dumps({"source": "local"}) + ) + isolated_home["global_config"].write_text( + json.dumps({"source": "global"}) + ) + cfg = _read_config() + assert cfg["source"] == "local" + + +class TestMultiProfileIsolation: + """Two profiles writing config don't interfere with each other.""" + + def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + + profile_a = tmp_path / "profile_a" + profile_b = tmp_path / "profile_b" + profile_a.mkdir() + profile_b.mkdir() + + # Profile A writes its config + monkeypatch.setenv("HERMES_HOME", str(profile_a)) + _write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}}) + + # Profile B writes its config + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + _write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}}) + + # Verify isolation + a_data = json.loads((profile_a / "honcho.json").read_text()) + b_data = json.loads((profile_b / "honcho.json").read_text()) + + assert a_data["hosts"]["hermes"]["peerName"] == "alice" + assert b_data["hosts"]["hermes"]["peerName"] == "bob" + + def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch): + """First setup reads global config, writes to local.""" + home = tmp_path / "home" + global_dir = home / ".honcho" + global_dir.mkdir(parents=True) + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + global_cfg_path = global_dir / "config.json" + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) + + # Existing global config + global_config = global_dir / "config.json" + global_config.write_text(json.dumps({ + "apiKey": "shared-key", + "hosts": {"hermes": {"workspace": "shared-ws"}}, + })) + + profile = tmp_path / "new_profile" + profile.mkdir() + monkeypatch.setenv("HERMES_HOME", str(profile)) + + # Read seeds from global + cfg = _read_config() + assert cfg["apiKey"] == "shared-key" + + # Modify and write goes to local + cfg["hosts"]["hermes"]["peerName"] = "new-user" + _write_config(cfg) + + local_config = profile / "honcho.json" + assert local_config.exists() + local_data = json.loads(local_config.read_text()) + assert local_data["hosts"]["hermes"]["peerName"] == "new-user" + + # Global unchanged + global_data = json.loads(global_config.read_text()) + assert "peerName" not in global_data["hosts"]["hermes"] From f007284d051900a424745dc4d4fb4bdcd78eff04 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:48:00 -0700 Subject: [PATCH 52/52] fix: rate-limit pairing rejection messages to prevent spam (#4081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: rate-limit pairing rejection messages to prevent spam When generate_code() returns None (rate limited or max pending), the "Too many pairing requests" message was sent on every subsequent DM with no cooldown. A user sending 30 messages would get 30 rejection replies — reported as potential hack on WhatsApp. Now check _is_rate_limited() before any pairing response, and record rate limit after sending a rejection. Subsequent messages from the same user are silently ignored until the rate limit window expires. * test: add coverage for pairing response rate limiting Follow-up to cherry-picked PR #4042 — adds tests verifying: - Rate-limited users get silently ignored (no response sent) - Rejection messages record rate limit for subsequent suppression --------- Co-authored-by: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> --- gateway/run.py | 7 +++ .../gateway/test_unauthorized_dm_behavior.py | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 7638d8a518..7358327445 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1702,6 +1702,11 @@ class GatewayRunner: # In DMs: offer pairing code. In groups: silently ignore. if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair": platform_name = source.platform.value if source.platform else "unknown" + # Rate-limit ALL pairing responses (code or rejection) to + # prevent spamming the user with repeated messages when + # multiple DMs arrive in quick succession. + if self.pairing_store._is_rate_limited(platform_name, source.user_id): + return None code = self.pairing_store.generate_code( platform_name, source.user_id, source.user_name or "" ) @@ -1723,6 +1728,8 @@ class GatewayRunner: "Too many pairing requests right now~ " "Please try again later!" ) + # Record rate limit so subsequent messages are silently ignored + self.pairing_store._record_rate_limit(platform_name, source.user_id) return None # PRIORITY handling when an agent is already running for this session. diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 02aae301c1..25b51dc2f2 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -60,6 +60,7 @@ def _make_runner(platform: Platform, config: GatewayConfig): runner.adapters = {platform: adapter} runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False + runner.pairing_store._is_rate_limited.return_value = False return runner, adapter @@ -142,6 +143,56 @@ async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch): adapter.send.assert_not_awaited() +@pytest.mark.asyncio +async def test_rate_limited_user_gets_no_response(monkeypatch): + """When a user is already rate-limited, pairing messages are silently ignored.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store._is_rate_limited.return_value = True + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rejection_message_records_rate_limit(monkeypatch): + """After sending a 'too many requests' rejection, rate limit is recorded + so subsequent messages are silently ignored.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store.generate_code.return_value = None # triggers rejection + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + adapter.send.assert_awaited_once() + assert "Too many" in adapter.send.await_args.args[1] + runner.pairing_store._record_rate_limit.assert_called_once_with( + "whatsapp", "15551234567@s.whatsapp.net" + ) + + @pytest.mark.asyncio async def test_global_ignore_suppresses_pairing_reply(monkeypatch): _clear_auth_env(monkeypatch)