diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index eec35fd62f2..6b360b8c641 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -69,9 +69,7 @@ jobs: file: Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: | - nousresearch/hermes-agent:latest - nousresearch/hermes-agent:${{ github.sha }} + tags: nousresearch/hermes-agent:latest cache-from: type=gha cache-to: type=gha,mode=max @@ -83,9 +81,6 @@ jobs: file: Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: | - nousresearch/hermes-agent:latest - nousresearch/hermes-agent:${{ github.event.release.tag_name }} - nousresearch/hermes-agent:${{ github.sha }} + tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 5c57897f572..4935d222ae9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,44 @@ +FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source +FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source FROM debian:13.4 # Disable Python stdout buffering to ensure logs are printed immediately ENV PYTHONUNBUFFERED=1 +# Store Playwright browsers outside the volume mount so the build-time +# install survives the /opt/data volume overlay at runtime. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright + # Install system dependencies in one layer, clear APT cache RUN apt-get update && \ apt-get install -y --no-install-recommends \ - build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \ + build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \ rm -rf /var/lib/apt/lists/* +# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime +RUN useradd -u 10000 -m -d /opt/data hermes + +COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/ +COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ + COPY . /opt/hermes WORKDIR /opt/hermes -# Install Python and Node dependencies in one layer, no cache -RUN pip install --no-cache-dir uv --break-system-packages && \ - uv pip install --system --break-system-packages --no-cache -e ".[all]" && \ - npm install --prefer-offline --no-audit && \ +# Install Node dependencies and Playwright as root (--with-deps needs apt) +RUN npm install --prefer-offline --no-audit && \ npx playwright install --with-deps chromium --only-shell && \ cd /opt/hermes/scripts/whatsapp-bridge && \ npm install --prefer-offline --no-audit && \ npm cache clean --force -WORKDIR /opt/hermes +# Hand ownership to hermes user, then install Python deps in a virtualenv +RUN chown -R hermes:hermes /opt/hermes +USER hermes + +RUN uv venv && \ + uv pip install --no-cache-dir -e ".[all]" + +USER root RUN chmod +x /opt/hermes/docker/entrypoint.sh ENV HERMES_HOME=/opt/data diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6b7bf196689..6f2f64e9fdc 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1021,6 +1021,23 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: _AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"}) +_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode") + + +def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]: + """Return a sanitized copy of a live main-runtime override.""" + if not isinstance(main_runtime, dict): + return {} + normalized: Dict[str, str] = {} + for field in _MAIN_RUNTIME_FIELDS: + value = main_runtime.get(field) + if isinstance(value, str) and value.strip(): + normalized[field] = value.strip() + provider = normalized.get("provider") + if provider: + normalized["provider"] = provider.lower() + return normalized + def _get_provider_chain() -> List[tuple]: """Return the ordered provider detection chain. @@ -1130,7 +1147,7 @@ def _try_payment_fallback( return None, None, "" -def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: +def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]: """Full auto-detection chain. Priority: @@ -1142,6 +1159,12 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: """ global auxiliary_is_nous, _stale_base_url_warned auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins + runtime = _normalize_main_runtime(main_runtime) + runtime_provider = runtime.get("provider", "") + runtime_model = runtime.get("model", "") + runtime_base_url = runtime.get("base_url", "") + runtime_api_key = runtime.get("api_key", "") + runtime_api_mode = runtime.get("api_mode", "") # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named # provider (not 'custom'). This catches the common "env poisoning" @@ -1149,7 +1172,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: # old OPENAI_BASE_URL lingers in ~/.hermes/.env. ── if not _stale_base_url_warned: _env_base = os.getenv("OPENAI_BASE_URL", "").strip() - _cfg_provider = _read_main_provider() + _cfg_provider = runtime_provider or _read_main_provider() if (_env_base and _cfg_provider and _cfg_provider != "custom" and not _cfg_provider.startswith("custom:")): @@ -1163,12 +1186,25 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: _stale_base_url_warned = True # ── Step 1: non-aggregator main provider → use main model directly ── - main_provider = _read_main_provider() - main_model = _read_main_model() + main_provider = runtime_provider or _read_main_provider() + main_model = runtime_model or _read_main_model() if (main_provider and main_model and main_provider not in _AGGREGATOR_PROVIDERS and main_provider not in ("auto", "")): - client, resolved = resolve_provider_client(main_provider, main_model) + resolved_provider = main_provider + explicit_base_url = None + explicit_api_key = None + if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")): + resolved_provider = "custom" + explicit_base_url = runtime_base_url + explicit_api_key = runtime_api_key or None + client, resolved = resolve_provider_client( + resolved_provider, + main_model, + explicit_base_url=explicit_base_url, + explicit_api_key=explicit_api_key, + api_mode=runtime_api_mode or None, + ) if client is not None: logger.info("Auxiliary auto-detect: using main provider %s (%s)", main_provider, resolved or main_model) @@ -1249,6 +1285,7 @@ def resolve_provider_client( explicit_base_url: str = None, explicit_api_key: str = None, api_mode: str = None, + main_runtime: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[Any], Optional[str]]: """Central router: given a provider name and optional model, return a configured client with the correct auth, base URL, and API format. @@ -1319,7 +1356,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── Auto: try all providers in priority order ──────────────────── if provider == "auto": - client, resolved = _resolve_auto() + client, resolved = _resolve_auto(main_runtime=main_runtime) if client is None: return None, None # When auto-detection lands on a non-OpenRouter provider (e.g. a @@ -1543,7 +1580,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── Public API ────────────────────────────────────────────────────────────── -def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optional[str]]: +def get_text_auxiliary_client( + task: str = "", + *, + main_runtime: Optional[Dict[str, Any]] = None, +) -> Tuple[Optional[OpenAI], Optional[str]]: """Return (client, default_model_slug) for text-only auxiliary tasks. Args: @@ -1560,10 +1601,11 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, + main_runtime=main_runtime, ) -def get_async_text_auxiliary_client(task: str = ""): +def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Dict[str, Any]] = None): """Return (async_client, model_slug) for async consumers. For standard providers returns (AsyncOpenAI, model). For Codex returns @@ -1578,6 +1620,7 @@ def get_async_text_auxiliary_client(task: str = ""): explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, + main_runtime=main_runtime, ) @@ -1892,6 +1935,7 @@ def _get_cached_client( base_url: str = None, api_key: str = None, api_mode: str = None, + main_runtime: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[Any], Optional[str]]: """Get or create a cached client for the given provider. @@ -1915,7 +1959,9 @@ def _get_cached_client( loop_id = id(current_loop) except RuntimeError: pass - cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id) + runtime = _normalize_main_runtime(main_runtime) + runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else () + cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id, runtime_key) with _client_cache_lock: if cache_key in _client_cache: cached_client, cached_default, cached_loop = _client_cache[cache_key] @@ -1940,6 +1986,7 @@ def _get_cached_client( explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, + main_runtime=runtime, ) if client is not None: # For async clients, remember which loop they were created on so we @@ -2065,6 +2112,75 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float return default +# --------------------------------------------------------------------------- +# Anthropic-compatible endpoint detection + image block conversion +# --------------------------------------------------------------------------- + +# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper). +# Their image content blocks must use Anthropic format, not OpenAI format. +_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"}) + + +def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool: + """Detect if an endpoint expects Anthropic-format content blocks. + + Returns True for known Anthropic-compatible providers (MiniMax) and + any endpoint whose URL contains ``/anthropic`` in the path. + """ + if provider in _ANTHROPIC_COMPAT_PROVIDERS: + return True + url_lower = (base_url or "").lower() + return "/anthropic" in url_lower + + +def _convert_openai_images_to_anthropic(messages: list) -> list: + """Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks. + + Only touches messages that have list-type content with ``image_url`` blocks; + plain text messages pass through unchanged. + """ + converted = [] + for msg in messages: + content = msg.get("content") + if not isinstance(content, list): + converted.append(msg) + continue + new_content = [] + changed = False + for block in content: + if block.get("type") == "image_url": + image_url_val = (block.get("image_url") or {}).get("url", "") + if image_url_val.startswith("data:"): + # Parse data URI: data:;base64, + header, _, b64data = image_url_val.partition(",") + media_type = "image/png" + if ":" in header and ";" in header: + media_type = header.split(":", 1)[1].split(";", 1)[0] + new_content.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": b64data, + }, + }) + else: + # URL-based image + new_content.append({ + "type": "image", + "source": { + "type": "url", + "url": image_url_val, + }, + }) + changed = True + else: + new_content.append(block) + converted.append({**msg, "content": new_content} if changed else msg) + return converted + + + def _build_call_kwargs( provider: str, model: str, @@ -2149,6 +2265,7 @@ def call_llm( model: str = None, base_url: str = None, api_key: str = None, + main_runtime: Optional[Dict[str, Any]] = None, messages: list, temperature: float = None, max_tokens: int = None, @@ -2214,6 +2331,7 @@ def call_llm( base_url=resolved_base_url, api_key=resolved_api_key, api_mode=resolved_api_mode, + main_runtime=main_runtime, ) if client is None: # When the user explicitly chose a non-OpenRouter provider but no @@ -2234,7 +2352,7 @@ def call_llm( if not resolved_base_url: logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) - client, final_model = _get_cached_client("auto") + client, final_model = _get_cached_client("auto", main_runtime=main_runtime) if client is None: raise RuntimeError( f"No LLM provider configured for task={task} provider={resolved_provider}. " @@ -2255,6 +2373,11 @@ def call_llm( tools=tools, timeout=effective_timeout, extra_body=extra_body, base_url=resolved_base_url) + # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) + _client_base = str(getattr(client, "base_url", "") or "") + if _is_anthropic_compat_endpoint(resolved_provider, _client_base): + kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"]) + # Handle max_tokens vs max_completion_tokens retry, then payment fallback. try: return _validate_llm_response( @@ -2331,9 +2454,9 @@ def extract_content_or_reasoning(response) -> str: if content: # Strip inline think/reasoning blocks (mirrors _strip_think_blocks) cleaned = re.sub( - r"<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>" + r"<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>" r".*?" - r"", + r"", "", content, flags=re.DOTALL | re.IGNORECASE, ).strip() if cleaned: @@ -2443,6 +2566,11 @@ async def async_call_llm( tools=tools, timeout=effective_timeout, extra_body=extra_body, base_url=resolved_base_url) + # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) + _client_base = str(getattr(client, "base_url", "") or "") + if _is_anthropic_compat_endpoint(resolved_provider, _client_base): + kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"]) + try: return _validate_llm_response( await client.chat.completions.create(**kwargs), task) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2701997fa6c..4163966aaaf 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -86,12 +86,14 @@ def update_model( base_url: str = "", api_key: str = "", provider: str = "", + api_mode: str = "", ) -> None: """Update model info after a model switch or fallback activation.""" self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider + self.api_mode = api_mode self.context_length = context_length self.threshold_tokens = max( int(context_length * self.threshold_percent), @@ -111,11 +113,13 @@ def __init__( api_key: str = "", config_context_length: int | None = None, provider: str = "", + api_mode: str = "", ): self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider + self.api_mode = api_mode self.threshold_percent = threshold_percent self.protect_first_n = protect_first_n self.protect_last_n = protect_last_n @@ -438,6 +442,13 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi try: call_kwargs = { "task": "compression", + "main_runtime": { + "model": self.model, + "provider": self.provider, + "base_url": self.base_url, + "api_key": self.api_key, + "api_mode": self.api_mode, + }, "messages": [{"role": "user", "content": prompt}], "max_tokens": summary_budget * 2, # timeout resolved from auxiliary.compression.timeout config by call_llm diff --git a/agent/credential_pool.py b/agent/credential_pool.py index bff262bdc01..e067fb90149 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -24,6 +24,7 @@ _codex_access_token_is_expiring, _decode_jwt_claims, _import_codex_cli_tokens, + _write_codex_cli_tokens, _load_auth_store, _load_provider_state, _resolve_kimi_base_url, @@ -693,6 +694,14 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po self._replace_entry(synced, updated) self._persist() self._sync_device_code_entry_to_auth_store(updated) + try: + _write_codex_cli_tokens( + updated.access_token, + updated.refresh_token, + last_refresh=updated.last_refresh, + ) + except Exception as wexc: + logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc) return updated except Exception as retry_exc: logger.debug("Codex retry refresh also failed: %s", retry_exc) @@ -718,6 +727,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po # _seed_from_singletons() on the next load_pool() sees fresh state # instead of re-seeding stale/consumed tokens. self._sync_device_code_entry_to_auth_store(updated) + # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI + # and VS Code don't hit "refresh_token_reused" on their next refresh. + if self.provider == "openai-codex": + try: + _write_codex_cli_tokens( + updated.access_token, + updated.refresh_token, + last_refresh=updated.last_refresh, + ) + except Exception as wexc: + logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc) return updated def _entry_needs_refresh(self, entry: PooledCredential) -> bool: @@ -1128,6 +1148,23 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup elif provider == "openai-codex": state = _load_provider_state(auth_store, "openai-codex") tokens = state.get("tokens") if isinstance(state, dict) else None + # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth + # store has no tokens. This mirrors resolve_codex_runtime_credentials() + # so that load_pool() and list_authenticated_providers() detect tokens + # that only exist in the Codex CLI shared file. + if not (isinstance(tokens, dict) and tokens.get("access_token")): + try: + from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens + cli_tokens = _import_codex_cli_tokens() + if cli_tokens: + logger.info("Importing Codex CLI tokens into Hermes auth store.") + _save_codex_tokens(cli_tokens) + # Re-read state after import + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "openai-codex") + tokens = state.get("tokens") if isinstance(state, dict) else None + except Exception as exc: + logger.debug("Codex CLI token import failed: %s", exc) if isinstance(tokens, dict) and tokens.get("access_token"): active_sources.add("device_code") changed |= _upsert_entry( diff --git a/agent/models_dev.py b/agent/models_dev.py index f9eb49dbf26..e20a2d4144f 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -144,6 +144,8 @@ class ProviderInfo: PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openrouter": "openrouter", "anthropic": "anthropic", + "openai": "openai", + "openai-codex": "openai", "zai": "zai", "kimi-coding": "kimi-for-coding", "minimax": "minimax", diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 26d913a0298..6eec0392bb6 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -12,7 +12,7 @@ from collections import OrderedDict from pathlib import Path -from hermes_constants import get_hermes_home, get_skills_dir +from hermes_constants import get_hermes_home, get_skills_dir, is_wsl from typing import Optional from agent.skill_utils import ( @@ -366,6 +366,36 @@ def _strip_yaml_frontmatter(content: str) -> str: ), } +# --------------------------------------------------------------------------- +# Environment hints — execution-environment awareness for the agent. +# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe +# the machine/OS the agent's tools actually run on. +# --------------------------------------------------------------------------- + +WSL_ENVIRONMENT_HINT = ( + "You are running inside WSL (Windows Subsystem for Linux). " + "The Windows host filesystem is mounted under /mnt/ — " + "/mnt/c/ is the C: drive, /mnt/d/ is D:, etc. " + "The user's Windows files are typically at " + "/mnt/c/Users//Desktop/, Documents/, Downloads/, etc. " + "When the user references Windows paths or desktop files, translate " + "to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover " + "the Windows username if needed." +) + + +def build_environment_hints() -> str: + """Return environment-specific guidance for the system prompt. + + Detects WSL, and can be extended for Termux, Docker, etc. + Returns an empty string when no special environment is detected. + """ + hints: list[str] = [] + if is_wsl(): + hints.append(WSL_ENVIRONMENT_HINT) + return "\n\n".join(hints) + + CONTEXT_FILE_MAX_CHARS = 20_000 CONTEXT_TRUNCATE_HEAD_RATIO = 0.7 CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 @@ -726,8 +756,16 @@ def build_skills_system_prompt( result = ( "## Skills (mandatory)\n" - "Before replying, scan the skills below. If one clearly matches your task, " - "load it with skill_view(name) and follow its instructions. " + "Before replying, scan the skills below. If a skill matches or is even partially relevant " + "to your task, you MUST load it with skill_view(name) and follow its instructions. " + "Err on the side of loading — it is always better to have context you don't need " + "than to miss critical steps, pitfalls, or established workflows. " + "Skills contain specialized knowledge — API endpoints, tool-specific commands, " + "and proven workflows that outperform general-purpose approaches. Load the skill " + "even if you think you could handle the task with basic tools like web_search or terminal. " + "Skills also encode the user's preferred approach, conventions, and quality standards " + "for tasks like code review, planning, and testing — load them even for tasks you " + "already know how to do, because the skill defines how it should be done here.\n" "If a skill has issues, fix it with skill_manage(action='patch').\n" "After difficult/iterative tasks, offer to save as a skill. " "If a skill you loaded was missing steps, had wrong commands, or needed " @@ -737,7 +775,7 @@ def build_skills_system_prompt( + "\n".join(index_lines) + "\n" "\n" "\n" - "If none match, proceed normally without loading a skill." + "Only proceed without loading a skill if genuinely none are relevant to the task." ) # ── Store in LRU cache ──────────────────────────────────────────── diff --git a/agent/title_generator.py b/agent/title_generator.py index 741fe8b09c5..d6ed9200a26 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -36,7 +36,7 @@ def generate_title(user_message: str, assistant_response: str, timeout: float = try: response = call_llm( - task="compression", # reuse compression task config (cheap/fast model) + task="title_generation", messages=messages, max_tokens=30, temperature=0.3, diff --git a/cli.py b/cli.py index b3d51b12710..09cb47e3843 100644 --- a/cli.py +++ b/cli.py @@ -1822,6 +1822,8 @@ def __init__( self._secret_deadline = 0 self._spinner_text: str = "" # thinking spinner text for TUI self._tool_start_time: float = 0.0 # monotonic timestamp when current tool started (for live elapsed) + self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback + self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup) self._command_running = False self._command_status = "" self._attached_images: list[Path] = [] @@ -2418,8 +2420,8 @@ def _stream_delta(self, text) -> None: # suppress them during streaming too — unless show_reasoning is # enabled, in which case we route the inner content to the # reasoning display box instead of discarding it. - _OPEN_TAGS = ("", "", "", "", "") - _CLOSE_TAGS = ("", "", "", "", "") + _OPEN_TAGS = ("", "", "", "", "", "") + _CLOSE_TAGS = ("", "", "", "", "", "") # Append to a pre-filter buffer first self._stream_prefilt = getattr(self, "_stream_prefilt", "") + text @@ -2733,6 +2735,22 @@ def _ensure_runtime_credentials(self) -> bool: if runtime_model and isinstance(runtime_model, str): self.model = runtime_model + # If model is still empty (e.g. user ran `hermes auth add openai-codex` + # without `hermes model`), fall back to the provider's first catalog + # model so the API call doesn't fail with "model must be non-empty". + if not self.model and resolved_provider: + try: + from hermes_cli.models import get_default_model_for_provider + _default = get_default_model_for_provider(resolved_provider) + if _default: + self.model = _default + logger.info( + "No model configured — defaulting to %s for provider %s", + _default, resolved_provider, + ) + except Exception: + pass + # Normalize model for the resolved provider (e.g. swap non-Codex # models when provider is openai-codex). Fixes #651. model_changed = self._normalize_model_for_provider(resolved_provider) @@ -5242,9 +5260,33 @@ def process_command(self, command: str) -> bool: context_length=ctx_len, ) _cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") + # Show a random tip on new session + try: + from hermes_cli.tips import get_random_tip + _tip = get_random_tip() + try: + from hermes_cli.skin_engine import get_active_skin + _tip_color = get_active_skin().get_color("banner_dim", "#B8860B") + except Exception: + _tip_color = "#B8860B" + cc.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + except Exception: + pass else: self.show_banner() print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") + # Show a random tip on new session + try: + from hermes_cli.tips import get_random_tip + _tip = get_random_tip() + try: + from hermes_cli.skin_engine import get_active_skin + _tip_color = get_active_skin().get_color("banner_dim", "#B8860B") + except Exception: + _tip_color = "#B8860B" + self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + except Exception: + pass elif canonical == "history": self.show_history() elif canonical == "title": @@ -6560,10 +6602,36 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: On tool.started, records a monotonic timestamp so get_spinner_text() can show a live elapsed timer (the TUI poll loop already invalidates every ~0.15s, so the counter updates automatically). + + When tool_progress_mode is "all" or "new", also prints a persistent + stacked line to scrollback on tool.completed so users can see the + full history of tool calls (not just the current one in the spinner). """ if event_type == "tool.completed": import time as _time self._tool_start_time = 0.0 + # Print stacked scrollback line for "all" / "new" modes + if function_name and self.tool_progress_mode in ("all", "new"): + duration = kwargs.get("duration", 0.0) + is_error = kwargs.get("is_error", False) + # Pop stored args from tool.started for this function + stored = self._pending_tool_info.get(function_name) + stored_args = stored.pop(0) if stored else {} + if stored is not None and not stored: + del self._pending_tool_info[function_name] + # "new" mode: skip consecutive repeats of the same tool + if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool: + self._invalidate() + return + self._last_scrollback_tool = function_name + try: + from agent.display import get_cute_tool_message + line = get_cute_tool_message(function_name, stored_args, duration) + if is_error: + line = f"{line} [error]" + _cprint(f" {line}") + except Exception: + pass self._invalidate() return if event_type != "tool.started": @@ -6579,6 +6647,10 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: label = label[:_pl - 3] + "..." self._spinner_text = f"{emoji} {label}" self._tool_start_time = _time.monotonic() + # Store args for stacked scrollback line on completion + self._pending_tool_info.setdefault(function_name, []).append( + function_args if function_args is not None else {} + ) self._invalidate() if not self._voice_mode: @@ -7545,8 +7617,10 @@ def run_agent(): "error": _summary, } - # Start agent in background thread - agent_thread = threading.Thread(target=run_agent) + # Start agent in background thread (daemon so it cannot keep the + # process alive when the user closes the terminal tab — SIGHUP + # exits the main thread and daemon threads are reaped automatically). + agent_thread = threading.Thread(target=run_agent, daemon=True) agent_thread.start() # Monitor the dedicated interrupt queue while the agent runs. @@ -8043,6 +8117,17 @@ def run(self): _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_color = "#FFF8DC" self.console.print(f"[{_welcome_color}]{_welcome_text}[/]") + # Show a random tip to help users discover features + try: + from hermes_cli.tips import get_random_tip + _tip = get_random_tip() + try: + _tip_color = _welcome_skin.get_color("banner_dim", "#B8860B") + except Exception: + _tip_color = "#B8860B" + self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + except Exception: + pass # Tips are non-critical — never break startup if self.preloaded_skills and not self._startup_skills_line_shown: skills_label = ", ".join(self.preloaded_skills) self.console.print( @@ -9318,9 +9403,14 @@ def process_loop(): from tools.process_registry import process_registry if not process_registry.completion_queue.empty(): evt = process_registry.completion_queue.get_nowait() - _synth = _format_process_notification(evt) - if _synth: - self._pending_input.put(_synth) + # Skip if the agent already consumed this via wait/poll/log + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): + pass # already delivered via tool result + else: + _synth = _format_process_notification(evt) + if _synth: + self._pending_input.put(_synth) except Exception: pass continue @@ -9419,6 +9509,8 @@ def _expand_ref(m): self._agent_running = False self._spinner_text = "" self._tool_start_time = 0.0 + self._pending_tool_info.clear() + self._last_scrollback_tool = "" app.invalidate() # Refresh status line @@ -9444,6 +9536,10 @@ def _restart_recording(): from tools.process_registry import process_registry while not process_registry.completion_queue.empty(): evt = process_registry.completion_queue.get_nowait() + # Skip if the agent already consumed this via wait/poll/log + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): + continue # already delivered via tool result _synth = _format_process_notification(evt) if _synth: self._pending_input.put(_synth) @@ -9475,17 +9571,37 @@ def _signal_handler(signum, frame): pass # Signal handlers may fail in restricted environments # Install a custom asyncio exception handler that suppresses the - # "Event loop is closed" RuntimeError from httpx transport cleanup. - # This is defense-in-depth — the primary fix is neuter_async_httpx_del - # which disables __del__ entirely, but older clients or SDK upgrades - # could bypass it. + # "Event loop is closed" RuntimeError from httpx transport cleanup + # and the "0 is not registered" KeyError from broken stdin (#6393). + # The RuntimeError fix is defense-in-depth — the primary fix is + # neuter_async_httpx_del which disables __del__ entirely. The + # KeyError fix handles macOS + uv-managed Python environments where + # fd 0 is not reliably available to the asyncio selector. def _suppress_closed_loop_errors(loop, context): exc = context.get("exception") if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc): return # silently suppress + if isinstance(exc, KeyError) and "is not registered" in str(exc): + return # suppress selector registration failures (#6393) # Fall back to default handler for everything else loop.default_exception_handler(context) + # Validate stdin before launching prompt_toolkit — on macOS with + # uv-managed Python, fd 0 can be invalid or unregisterable with the + # asyncio selector, causing "KeyError: '0 is not registered'" (#6393). + try: + import os as _os + _os.fstat(0) + except OSError: + print( + "Error: stdin (fd 0) is not available.\n" + "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + ) + _run_cleanup() + self._print_exit_summary() + return + # Run the application with patch_stdout for proper output handling try: with patch_stdout(): @@ -9499,8 +9615,28 @@ def _suppress_closed_loop_errors(loop, context): app.run() except (EOFError, KeyboardInterrupt, BrokenPipeError): pass + except (KeyError, OSError) as _stdin_err: + # Catch selector registration failures from broken stdin (#6393). + # This is the fallback for cases that slip past the fstat() guard. + if "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err): + print( + f"\nError: stdin is not usable ({_stdin_err}).\n" + "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + ) + else: + raise finally: self._should_exit = True + # Interrupt the agent immediately so its daemon thread stops making + # API calls and exits promptly (agent_thread is daemon, so the + # process will exit once the main thread finishes, but interrupting + # avoids wasted API calls and lets run_conversation clean up). + if self.agent and getattr(self, '_agent_running', False): + try: + self.agent.interrupt() + except Exception: + pass # Flush memories before exit (only for substantial conversations) if self.agent and self.conversation_history: try: diff --git a/cron/scheduler.py b/cron/scheduler.py index 1848cb29a02..e6db77c0989 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -219,6 +219,21 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option chat_id = target["chat_id"] thread_id = target.get("thread_id") + # Diagnostic: log thread_id for topic-aware delivery debugging + origin = job.get("origin") or {} + origin_thread = origin.get("thread_id") + if origin_thread and not thread_id: + logger.warning( + "Job '%s': origin has thread_id=%s but delivery target lost it " + "(deliver=%s, target=%s)", + job["id"], origin_thread, job.get("deliver", "local"), target, + ) + elif thread_id: + logger.debug( + "Job '%s': delivering to %s:%s thread_id=%s", + job["id"], platform_name, chat_id, thread_id, + ) + from tools.send_message_tool import _send_to_platform from gateway.config import load_gateway_config, Platform @@ -626,6 +641,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: except Exception as e: logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e) + # Apply IPv4 preference if configured. + try: + from hermes_constants import apply_ipv4_preference + _net_cfg = _cfg.get("network", {}) + if isinstance(_net_cfg, dict) and _net_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) + except Exception: + pass + # Reasoning config from config.yaml from hermes_constants import parse_reasoning_effort effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip() diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 68e3b79c1d1..dc1edd32c24 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,6 +5,33 @@ set -e HERMES_HOME="/opt/data" INSTALL_DIR="/opt/hermes" +# --- Privilege dropping via gosu --- +# When started as root (the default), optionally remap the hermes user/group +# to match host-side ownership, fix volume permissions, then re-exec as hermes. +if [ "$(id -u)" = "0" ]; then + if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then + echo "Changing hermes UID to $HERMES_UID" + usermod -u "$HERMES_UID" hermes + fi + + if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then + echo "Changing hermes GID to $HERMES_GID" + groupmod -g "$HERMES_GID" hermes + fi + + actual_hermes_uid=$(id -u hermes) + if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then + echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing" + chown -R hermes:hermes "$HERMES_HOME" + fi + + echo "Dropping root privileges" + exec gosu hermes "$0" "$@" +fi + +# --- Running as hermes from here --- +source "${INSTALL_DIR}/.venv/bin/activate" + # Create essential directory structure. Cache and platform directories # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on # demand by the application — don't pre-create them here so new installs diff --git a/docs/migration/openclaw.md b/docs/migration/openclaw.md index 8545636abd3..30f2f97e4d6 100644 --- a/docs/migration/openclaw.md +++ b/docs/migration/openclaw.md @@ -118,7 +118,7 @@ For executed migrations, the full report is saved to `~/.hermes/migration/opencl ## Troubleshooting ### "OpenClaw directory not found" -The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moldbot`. If your OpenClaw is installed elsewhere, use `--source`: +The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moltbot`. If your OpenClaw is installed elsewhere, use `--source`: ```bash hermes claw migrate --source /path/to/.openclaw ``` diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 16f5467b220..7fce74def38 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -34,6 +34,9 @@ from pathlib import Path from types import SimpleNamespace from typing import Any, Dict, List, Optional +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen # aiohttp/websockets are independent optional deps — import outside lark_oapi # so they remain available for tests and webhook mode even if lark_oapi is missing. @@ -169,6 +172,19 @@ _FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback _FEISHU_ACK_EMOJI = "OK" + +# QR onboarding constants +_ONBOARD_ACCOUNTS_URLS = { + "feishu": "https://accounts.feishu.cn", + "lark": "https://accounts.larksuite.com", +} +_ONBOARD_OPEN_URLS = { + "feishu": "https://open.feishu.cn", + "lark": "https://open.larksuite.com", +} +_REGISTRATION_PATH = "/oauth/v1/app/registration" +_ONBOARD_REQUEST_TIMEOUT_S = 10 + # --------------------------------------------------------------------------- # Fallback display strings # --------------------------------------------------------------------------- @@ -3621,3 +3637,328 @@ def _resolve_outbound_file_routing( return _FEISHU_FILE_UPLOAD_TYPE, "file" return _FEISHU_FILE_UPLOAD_TYPE, "file" + + +# ============================================================================= +# QR scan-to-create onboarding +# +# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the +# platform creates a fully configured bot application automatically. +# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py. +# ============================================================================= + + +def _accounts_base_url(domain: str) -> str: + return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"]) + + +def _onboard_open_base_url(domain: str) -> str: + return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"]) + + +def _post_registration(base_url: str, body: Dict[str, str]) -> dict: + """POST form-encoded data to the registration endpoint, return parsed JSON. + + The registration endpoint returns JSON even on 4xx (e.g. poll returns + authorization_pending as a 400). We always parse the body regardless of + HTTP status. + """ + url = f"{base_url}{_REGISTRATION_PATH}" + data = urlencode(body).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + body_bytes = exc.read() + if body_bytes: + try: + return json.loads(body_bytes.decode("utf-8")) + except (ValueError, json.JSONDecodeError): + raise exc from None + raise + + +def _init_registration(domain: str = "feishu") -> None: + """Verify the environment supports client_secret auth. + + Raises RuntimeError if not supported. + """ + base_url = _accounts_base_url(domain) + res = _post_registration(base_url, {"action": "init"}) + methods = res.get("supported_auth_methods") or [] + if "client_secret" not in methods: + raise RuntimeError( + f"Feishu / Lark registration environment does not support client_secret auth. " + f"Supported: {methods}" + ) + + +def _begin_registration(domain: str = "feishu") -> dict: + """Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in.""" + base_url = _accounts_base_url(domain) + res = _post_registration(base_url, { + "action": "begin", + "archetype": "PersonalAgent", + "auth_method": "client_secret", + "request_user_info": "open_id", + }) + device_code = res.get("device_code") + if not device_code: + raise RuntimeError("Feishu / Lark registration did not return a device_code") + qr_url = res.get("verification_uri_complete", "") + if "?" in qr_url: + qr_url += "&from=hermes&tp=hermes" + else: + qr_url += "?from=hermes&tp=hermes" + return { + "device_code": device_code, + "qr_url": qr_url, + "user_code": res.get("user_code", ""), + "interval": res.get("interval") or 5, + "expire_in": res.get("expire_in") or 600, + } + + +def _poll_registration( + *, + device_code: str, + interval: int, + expire_in: int, + domain: str = "feishu", +) -> Optional[dict]: + """Poll until the user scans the QR code, or timeout/denial. + + Returns dict with app_id, app_secret, domain, open_id on success. + Returns None on failure. + """ + deadline = time.time() + expire_in + current_domain = domain + domain_switched = False + poll_count = 0 + + while time.time() < deadline: + base_url = _accounts_base_url(current_domain) + try: + res = _post_registration(base_url, { + "action": "poll", + "device_code": device_code, + "tp": "ob_app", + }) + except (URLError, OSError, json.JSONDecodeError): + time.sleep(interval) + continue + + poll_count += 1 + if poll_count == 1: + print(" Fetching configuration results...", end="", flush=True) + elif poll_count % 6 == 0: + print(".", end="", flush=True) + + # Domain auto-detection + user_info = res.get("user_info") or {} + tenant_brand = user_info.get("tenant_brand") + if tenant_brand == "lark" and not domain_switched: + current_domain = "lark" + domain_switched = True + # Fall through — server may return credentials in this same response. + + # Success + if res.get("client_id") and res.get("client_secret"): + if poll_count > 0: + print() # newline after "Fetching configuration results..." dots + return { + "app_id": res["client_id"], + "app_secret": res["client_secret"], + "domain": current_domain, + "open_id": user_info.get("open_id"), + } + + # Terminal errors + error = res.get("error", "") + if error in ("access_denied", "expired_token"): + if poll_count > 0: + print() + logger.warning("[Feishu onboard] Registration %s", error) + return None + + # authorization_pending or unknown — keep polling + time.sleep(interval) + + if poll_count > 0: + print() + logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in) + return None + + +try: + import qrcode as _qrcode_mod +except (ImportError, TypeError): + _qrcode_mod = None # type: ignore[assignment] + + +def _render_qr(url: str) -> bool: + """Try to render a QR code in the terminal. Returns True if successful.""" + if _qrcode_mod is None: + return False + try: + qr = _qrcode_mod.QRCode() + qr.add_data(url) + qr.make(fit=True) + qr.print_ascii(invert=True) + return True + except Exception: + return False + + +def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Verify bot connectivity via /open-apis/bot/v3/info. + + Uses lark_oapi SDK when available, falls back to raw HTTP otherwise. + Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure. + """ + if FEISHU_AVAILABLE: + return _probe_bot_sdk(app_id, app_secret, domain) + return _probe_bot_http(app_id, app_secret, domain) + + +def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any: + """Build a lark Client for the given credentials and domain.""" + sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN + return ( + lark.Client.builder() + .app_id(app_id) + .app_secret(app_secret) + .domain(sdk_domain) + .log_level(lark.LogLevel.WARNING) + .build() + ) + + +def _parse_bot_response(data: dict) -> Optional[dict]: + """Extract bot_name and bot_open_id from a /bot/v3/info response.""" + if data.get("code") != 0: + return None + bot = data.get("bot") or data.get("data", {}).get("bot") or {} + return { + "bot_name": bot.get("bot_name"), + "bot_open_id": bot.get("open_id"), + } + + +def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Probe bot info using lark_oapi SDK.""" + try: + client = _build_onboard_client(app_id, app_secret, domain) + resp = client.request( + method="GET", + url="/open-apis/bot/v3/info", + body=None, + raw_response=True, + ) + return _parse_bot_response(json.loads(resp.content)) + except Exception as exc: + logger.debug("[Feishu onboard] SDK probe failed: %s", exc) + return None + + +def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Fallback probe using raw HTTP (when lark_oapi is not installed).""" + base_url = _onboard_open_base_url(domain) + try: + token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8") + token_req = Request( + f"{base_url}/open-apis/auth/v3/tenant_access_token/internal", + data=token_data, + headers={"Content-Type": "application/json"}, + ) + with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + token_res = json.loads(resp.read().decode("utf-8")) + + access_token = token_res.get("tenant_access_token") + if not access_token: + return None + + bot_req = Request( + f"{base_url}/open-apis/bot/v3/info", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + bot_res = json.loads(resp.read().decode("utf-8")) + + return _parse_bot_response(bot_res) + except (URLError, OSError, KeyError, json.JSONDecodeError) as exc: + logger.debug("[Feishu onboard] HTTP probe failed: %s", exc) + return None + + +def qr_register( + *, + initial_domain: str = "feishu", + timeout_seconds: int = 600, +) -> Optional[dict]: + """Run the Feishu / Lark scan-to-create QR registration flow. + + Returns on success:: + + { + "app_id": str, + "app_secret": str, + "domain": "feishu" | "lark", + "open_id": str | None, + "bot_name": str | None, + "bot_open_id": str | None, + } + + Returns None on expected failures (network, auth denied, timeout). + Unexpected errors (bugs, protocol regressions) propagate to the caller. + """ + try: + return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds) + except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc: + logger.warning("[Feishu onboard] Registration failed: %s", exc) + return None + + +def _qr_register_inner( + *, + initial_domain: str, + timeout_seconds: int, +) -> Optional[dict]: + """Run init → begin → poll → probe. Raises on network/protocol errors.""" + print(" Connecting to Feishu / Lark...", end="", flush=True) + _init_registration(initial_domain) + begin = _begin_registration(initial_domain) + print(" done.") + + print() + qr_url = begin["qr_url"] + if _render_qr(qr_url): + print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}") + else: + print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n") + print(" Tip: pip install qrcode to display a scannable QR code here next time") + print() + + result = _poll_registration( + device_code=begin["device_code"], + interval=begin["interval"], + expire_in=min(begin["expire_in"], timeout_seconds), + domain=initial_domain, + ) + if not result: + return None + + # Probe bot — best-effort, don't fail the registration + bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"]) + if bot_info: + result["bot_name"] = bot_info.get("bot_name") + result["bot_open_id"] = bot_info.get("bot_open_id") + else: + result["bot_name"] = None + result["bot_open_id"] = None + + return result diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 75d7e9c9f64..9f3d6358c77 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -18,6 +18,7 @@ MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) + MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) """ @@ -508,6 +509,19 @@ async def connect(self) -> bool: await api.session.close() return False + # Import cross-signing private keys from SSSS and self-sign + # the current device. Required after any device-key rotation + # (fresh crypto.db, share_keys re-upload) — otherwise the + # device's self-signing signature is stale and peers refuse + # to share Megolm sessions with the rotated device. + recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip() + if recovery_key: + try: + await olm.verify_with_recovery_key(recovery_key) + logger.info("Matrix: cross-signing verified via recovery key") + except Exception as exc: + logger.warning("Matrix: recovery key verification failed: %s", exc) + client.crypto = olm logger.info( "Matrix: E2EE enabled (store: %s%s)", diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 5821d922f8c..dc4e7cf9698 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -734,6 +734,42 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]: return [unit for unit in units if unit] +def _looks_like_chatty_line_for_weixin(line: str) -> bool: + """Return True when a line looks like a standalone chat utterance.""" + stripped = line.strip() + if not stripped: + return False + if len(stripped) > 48: + return False + if line.startswith((" ", "\t")): + return False + if stripped.startswith((">", "-", "*", "【")): + return False + if re.match(r"^\*\*[^*]+\*\*$", stripped): + return False + if re.match(r"^\d+\.\s", stripped): + return False + return True + + +def _looks_like_heading_line_for_weixin(line: str) -> bool: + """Return True when a short line behaves like a plain-text heading.""" + stripped = line.strip() + if not stripped: + return False + return len(stripped) <= 24 and stripped.endswith((":", ":")) + + +def _should_split_short_chat_block_for_weixin(block: str) -> bool: + """Split only chat-like multiline blocks into separate bubbles.""" + lines = [line for line in block.splitlines() if line.strip()] + if not 2 <= len(lines) <= 6: + return False + if _looks_like_heading_line_for_weixin(lines[0]): + return False + return all(_looks_like_chatty_line_for_weixin(line) for line in lines) + + def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]: if len(content) <= max_length: return [content] @@ -787,9 +823,15 @@ def _split_text_for_weixin_delivery( chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length)) return chunks or [content] - # Compact (default): single message when under the limit. + # Compact (default): single message when under the limit — unless the + # content looks like a short chatty exchange, in which case split into + # separate bubbles for a more natural chat feel. if len(content) <= max_length: - return [content] + return ( + _split_delivery_units_for_weixin(content) + if _should_split_short_chat_block_for_weixin(content) + else [content] + ) return _pack_markdown_blocks_for_weixin(content, max_length) or [content] diff --git a/gateway/run.py b/gateway/run.py index b4924f8f371..94f1dde5323 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -186,6 +186,8 @@ def _ensure_ssl_certs() -> None: os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"]) if "gateway_timeout_warning" in _agent_cfg and "HERMES_AGENT_TIMEOUT_WARNING" not in os.environ: os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"]) + if "gateway_notify_interval" in _agent_cfg and "HERMES_AGENT_NOTIFY_INTERVAL" not in os.environ: + os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"]) if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ: os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"]) _display_cfg = _cfg.get("display", {}) @@ -206,6 +208,15 @@ def _ensure_ssl_certs() -> None: except Exception: pass # Non-fatal; gateway can still run with .env values +# Apply IPv4 preference if configured (before any HTTP clients are created). +try: + from hermes_constants import apply_ipv4_preference + _network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {}) + if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) +except Exception: + pass + # Validate config structure early — log warnings so gateway operators see problems try: from hermes_cli.config import print_config_warnings @@ -867,13 +878,47 @@ def _resolve_session_agent_runtime( "api_mode": override.get("api_mode"), } if override_runtime.get("api_key"): + logger.debug( + "Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s", + (resolved_session_key or "")[:30], model, override_model, + override_runtime.get("provider"), + ) return override_model, override_runtime + # Override exists but has no api_key — fall through to env-based + # resolution and apply model/provider from the override on top. + logger.debug( + "Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s", + (resolved_session_key or "")[:30], model, override_model, + ) + else: + logger.debug( + "No session model override: session=%s config_model=%s override_keys=%s", + (resolved_session_key or "")[:30], model, + list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]", + ) runtime_kwargs = _resolve_runtime_agent_kwargs() if override and resolved_session_key: model, runtime_kwargs = self._apply_session_model_override( resolved_session_key, model, runtime_kwargs ) + + # When the config has no model.default but a provider was resolved + # (e.g. user ran `hermes auth add openai-codex` without `hermes model`), + # fall back to the provider's first catalog model so the API call + # doesn't fail with "model must be a non-empty string". + if not model and runtime_kwargs.get("provider"): + try: + from hermes_cli.models import get_default_model_for_provider + model = get_default_model_for_provider(runtime_kwargs["provider"]) + if model: + logger.info( + "No model configured — defaulting to %s for provider %s", + model, runtime_kwargs["provider"], + ) + except Exception: + pass + return model, runtime_kwargs def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: @@ -1492,12 +1537,25 @@ async def start(self) -> bool: # This prevents stuck sessions from being blindly resumed on restart, # which can create an unrecoverable loop (#7536). Suspended sessions # auto-reset on the next incoming message, giving the user a clean start. - try: - suspended = self.session_store.suspend_recently_active() - if suspended: - logger.info("Suspended %d in-flight session(s) from previous run", suspended) - except Exception as e: - logger.warning("Session suspension on startup failed: %s", e) + # + # SKIP suspension after a clean (graceful) shutdown — the previous + # process already drained active agents, so sessions aren't stuck. + # This prevents unwanted auto-resets after `hermes update`, + # `hermes gateway restart`, or `/restart`. + _clean_marker = _hermes_home / ".clean_shutdown" + if _clean_marker.exists(): + logger.info("Previous gateway exited cleanly — skipping session suspension") + try: + _clean_marker.unlink() + except Exception: + pass + else: + try: + suspended = self.session_store.suspend_recently_active() + if suspended: + logger.info("Suspended %d in-flight session(s) from previous run", suspended) + except Exception as e: + logger.warning("Session suspension on startup failed: %s", e) connected_count = 0 enabled_platform_count = 0 @@ -2023,6 +2081,15 @@ async def _stop_impl() -> None: from gateway.status import remove_pid_file remove_pid_file() + # Write a clean-shutdown marker so the next startup knows this + # wasn't a crash. suspend_recently_active() only needs to run + # after unexpected exits — graceful shutdowns already drain + # active agents, so there's no stuck-session risk. + try: + (_hermes_home / ".clean_shutdown").touch() + except Exception: + pass + if self._restart_requested and self._restart_via_service: self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE self._exit_reason = self._exit_reason or "Gateway restart requested" @@ -3956,9 +4023,16 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: except Exception: pass + # Append a random tip to the reset message + try: + from hermes_cli.tips import get_random_tip + _tip_line = f"\n✦ Tip: {get_random_tip()}" + except Exception: + _tip_line = "" + if session_info: - return f"{header}\n\n{session_info}" - return header + return f"{header}\n\n{session_info}{_tip_line}" + return f"{header}{_tip_line}" async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show active profile name and home directory.""" @@ -4288,6 +4362,11 @@ async def _on_model_selected( "api_mode": result.api_mode, } + # Evict cached agent so the next turn creates a fresh + # agent from the override rather than relying on the + # stale cache signature to trigger a rebuild. + _self._evict_cached_agent(_session_key) + # Build confirmation text plabel = result.provider_label or result.target_provider lines = [f"Model switched to `{result.new_model}`"] @@ -4401,6 +4480,10 @@ async def _on_model_selected( "api_mode": result.api_mode, } + # Evict cached agent so the next turn creates a fresh agent from the + # override rather than relying on cache signature mismatch detection. + self._evict_cached_agent(session_key) + # Persist to config if --global if persist_global: try: @@ -6587,8 +6670,12 @@ async def _flush_buffer() -> None: if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval: await _flush_buffer() - # Check for prompts - if prompt_path.exists() and session_key: + # Check for prompts — only forward if we haven't already sent + # one that's still awaiting a response. Without this guard the + # watcher would re-read the same .update_prompt.json every poll + # cycle and spam the user with duplicate prompt messages. + if (prompt_path.exists() and session_key + and not self._update_prompt_pending.get(session_key)): try: prompt_data = json.loads(prompt_path.read_text()) prompt_text = prompt_data.get("prompt", "") @@ -6620,6 +6707,11 @@ async def _flush_buffer() -> None: f"or type your answer directly." ) self._update_prompt_pending[session_key] = True + # Remove the prompt file so it isn't re-read on the + # next poll cycle. The update process only needs + # .update_response to continue — it doesn't re-check + # .update_prompt.json while waiting. + prompt_path.unlink(missing_ok=True) logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80]) except (json.JSONDecodeError, OSError) as e: logger.debug("Failed to read update prompt: %s", e) @@ -6993,7 +7085,9 @@ async def _run_process_watcher(self, watcher: dict) -> None: if session.exited: # --- Agent-triggered completion: inject synthetic message --- - if agent_notify: + # Skip if the agent already consumed the result via wait/poll/log + from tools.process_registry import process_registry as _pr_check + if agent_notify and not _pr_check.is_completion_consumed(session_id): from tools.ansi_strip import strip_ansi _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" synth_text = ( @@ -7527,6 +7621,10 @@ def run_sync(): session_key=session_key, user_config=user_config, ) + logger.debug( + "run_agent resolved: model=%s provider=%s session=%s", + model, runtime_kwargs.get("provider"), (session_key or "")[:30], + ) except Exception as exc: return { "final_response": f"⚠️ Provider authentication failed: {exc}", @@ -8001,35 +8099,66 @@ async def track_agent(): tracking_task = asyncio.create_task(track_agent()) - # Monitor for interrupts from the adapter (new messages arriving) + # Monitor for interrupts from the adapter (new messages arriving). + # This is the PRIMARY interrupt path for regular text messages — + # Level 1 (base.py) catches them before _handle_message() is reached, + # so the Level 2 running_agent.interrupt() path never fires. + # The inactivity poll loop below has a BACKUP check in case this + # task dies (no error handling = silent death = lost interrupts). + _interrupt_detected = asyncio.Event() # shared with backup check + async def monitor_for_interrupt(): - adapter = self.adapters.get(source.platform) - if not adapter or not session_key: + if not session_key: return - + while True: await asyncio.sleep(0.2) # Check every 200ms - # Check if adapter has a pending interrupt for this session. - # Must use session_key (build_session_key output) — NOT - # source.chat_id — because the adapter stores interrupt events - # under the full session key. - if hasattr(adapter, 'has_pending_interrupt') and adapter.has_pending_interrupt(session_key): - agent = agent_holder[0] - if agent: - pending_event = adapter.get_pending_message(session_key) - pending_text = pending_event.text if pending_event else None - logger.debug("Interrupt detected from adapter, signaling agent...") - agent.interrupt(pending_text) - break + try: + # Re-resolve adapter each iteration so reconnects don't + # leave us holding a stale reference. + _adapter = self.adapters.get(source.platform) + if not _adapter: + continue + # Check if adapter has a pending interrupt for this session. + # Must use session_key (build_session_key output) — NOT + # source.chat_id — because the adapter stores interrupt events + # under the full session key. + if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key): + agent = agent_holder[0] + if agent: + # Peek at the pending message text WITHOUT consuming it. + # The message must remain in _pending_messages so the + # post-run dequeue at _dequeue_pending_event() can + # retrieve the full MessageEvent (with media metadata). + # If we pop here, a race exists: the agent may finish + # before checking _interrupt_requested, and the message + # is lost — neither the interrupt path nor the dequeue + # path finds it. + _peek_event = _adapter._pending_messages.get(session_key) + pending_text = _peek_event.text if _peek_event else None + logger.debug("Interrupt detected from adapter, signaling agent...") + agent.interrupt(pending_text) + _interrupt_detected.set() + break + except asyncio.CancelledError: + raise + except Exception as _mon_err: + logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err) interrupt_monitor = asyncio.create_task(monitor_for_interrupt()) # Periodic "still working" notifications for long-running tasks. - # Fires every 10 minutes so the user knows the agent hasn't died. - _NOTIFY_INTERVAL = 600 # 10 minutes + # Fires every N seconds so the user knows the agent hasn't died. + # Config: agent.gateway_notify_interval in config.yaml, or + # HERMES_AGENT_NOTIFY_INTERVAL env var. Default 600s (10 min). + # 0 = disable notifications. + _NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600)) + _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None _notify_start = time.time() async def _notify_long_running(): + if _NOTIFY_INTERVAL is None: + return # Notifications disabled (gateway_notify_interval: 0) _notify_adapter = self.adapters.get(source.platform) if not _notify_adapter: return @@ -8085,8 +8214,34 @@ async def _notify_long_running(): _POLL_INTERVAL = 5.0 if _agent_timeout is None: - # Unlimited — just await the result. - response = await _executor_task + # Unlimited — still poll periodically for backup interrupt + # detection in case monitor_for_interrupt() silently died. + response = None + while True: + done, _ = await asyncio.wait( + {_executor_task}, timeout=_POLL_INTERVAL + ) + if done: + response = _executor_task.result() + break + # Backup interrupt check: if the monitor task died or + # missed the interrupt, catch it here. + if not _interrupt_detected.is_set() and session_key: + _backup_adapter = self.adapters.get(source.platform) + _backup_agent = agent_holder[0] + if (_backup_adapter and _backup_agent + and hasattr(_backup_adapter, 'has_pending_interrupt') + and _backup_adapter.has_pending_interrupt(session_key)): + _bp_event = _backup_adapter._pending_messages.get(session_key) + _bp_text = _bp_event.text if _bp_event else None + logger.info( + "Backup interrupt detected for session %s " + "(monitor task state: %s)", + session_key[:20], + "done" if interrupt_monitor.done() else "running", + ) + _backup_agent.interrupt(_bp_text) + _interrupt_detected.set() else: # Poll loop: check the agent's built-in activity tracker # (updated by _touch_activity() on every tool call, API @@ -8130,6 +8285,23 @@ async def _notify_long_running(): if _idle_secs >= _agent_timeout: _inactivity_timeout = True break + # Backup interrupt check (same as unlimited path). + if not _interrupt_detected.is_set() and session_key: + _backup_adapter = self.adapters.get(source.platform) + _backup_agent = agent_holder[0] + if (_backup_adapter and _backup_agent + and hasattr(_backup_adapter, 'has_pending_interrupt') + and _backup_adapter.has_pending_interrupt(session_key)): + _bp_event = _backup_adapter._pending_messages.get(session_key) + _bp_text = _bp_event.text if _bp_event else None + logger.info( + "Backup interrupt detected for session %s " + "(monitor task state: %s)", + session_key[:20], + "done" if interrupt_monitor.done() else "running", + ) + _backup_agent.interrupt(_bp_text) + _interrupt_detected.set() if _inactivity_timeout: # Build a diagnostic summary from the agent's activity tracker. diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 56b9fb63c2e..04a7d0c1379 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1303,6 +1303,49 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: } +def _write_codex_cli_tokens( + access_token: str, + refresh_token: str, + *, + last_refresh: Optional[str] = None, +) -> None: + """Write refreshed tokens back to ~/.codex/auth.json. + + OpenAI OAuth refresh tokens are single-use and rotate on every refresh. + When Hermes refreshes a token it consumes the old refresh_token; if we + don't write the new pair back, the Codex CLI (or VS Code extension) will + fail with ``refresh_token_reused`` on its next refresh attempt. + + This mirrors the Anthropic write-back to ~/.claude/.credentials.json + via ``_write_claude_code_credentials()``. + """ + codex_home = os.getenv("CODEX_HOME", "").strip() + if not codex_home: + codex_home = str(Path.home() / ".codex") + auth_path = Path(codex_home).expanduser() / "auth.json" + try: + existing: Dict[str, Any] = {} + if auth_path.is_file(): + existing = json.loads(auth_path.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + existing = {} + + tokens_dict = existing.get("tokens") + if not isinstance(tokens_dict, dict): + tokens_dict = {} + tokens_dict["access_token"] = access_token + tokens_dict["refresh_token"] = refresh_token + existing["tokens"] = tokens_dict + if last_refresh is not None: + existing["last_refresh"] = last_refresh + + auth_path.parent.mkdir(parents=True, exist_ok=True) + auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + auth_path.chmod(0o600) + except (OSError, IOError) as exc: + logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc) + + def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" if last_refresh is None: @@ -1425,6 +1468,12 @@ def _refresh_codex_auth_tokens( updated_tokens["refresh_token"] = refreshed["refresh_token"] _save_codex_tokens(updated_tokens) + # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync. + _write_codex_cli_tokens( + refreshed["access_token"], + refreshed["refresh_token"], + last_refresh=refreshed.get("last_refresh"), + ) return updated_tokens diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index d0bfd73d23a..0f9e28cbcce 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -50,7 +50,7 @@ ) # Known OpenClaw directory names (current + legacy) -_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot") +_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") def _warn_if_gateway_running(auto_yes: bool) -> None: """Check if a Hermes gateway is running with connected platforms. @@ -87,8 +87,8 @@ def _warn_if_gateway_running(auto_yes: bool) -> None: print_info("Migration cancelled. Stop the gateway and try again.") sys.exit(0) -# State files commonly found in OpenClaw workspace directories that cause -# confusion after migration (the agent discovers them and writes to them) +# State files commonly found in OpenClaw workspace directories — listed +# during cleanup to help the user decide whether to archive _WORKSPACE_STATE_GLOBS = ( "*/todo.json", "*/sessions/*", @@ -133,7 +133,7 @@ def _find_openclaw_dirs() -> list[Path]: def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: - """Scan an OpenClaw directory for workspace state files that cause confusion. + """Scan an OpenClaw directory for workspace state files. Returns a list of (path, description) tuples. """ @@ -216,7 +216,7 @@ def _cmd_migrate(args): source_dir = Path.home() / ".openclaw" if not source_dir.is_dir(): # Try legacy directory names - for legacy in (".clawdbot", ".moldbot"): + for legacy in (".clawdbot", ".moltbot"): candidate = Path.home() / legacy if candidate.is_dir(): source_dir = candidate @@ -384,65 +384,16 @@ def _cmd_migrate(args): # Print results _print_migration_report(report, dry_run=False) - # After successful migration, offer to archive the source directory - if report.get("summary", {}).get("migrated", 0) > 0: - _offer_source_archival(source_dir, auto_yes) - - -def _offer_source_archival(source_dir: Path, auto_yes: bool = False): - """After migration, offer to rename the source directory to prevent state fragmentation. - - OpenClaw workspace directories contain state files (todo.json, sessions, etc.) - that the agent may discover and write to, causing confusion. Renaming the - directory prevents this. - """ - if not source_dir.is_dir(): - return - - # Scan for state files that could cause problems - state_files = _scan_workspace_state(source_dir) - - print() - print_header("Post-Migration Cleanup") - print_info("The OpenClaw directory still exists and contains workspace state files") - print_info("that can confuse the agent (todo lists, sessions, logs).") - if state_files: - print() - print(color(" Found state files:", Colors.YELLOW)) - # Show up to 10 most relevant findings - for path, desc in state_files[:10]: - print(f" {desc}") - if len(state_files) > 10: - print(f" ... and {len(state_files) - 10} more") - print() - print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/") - print_info("This prevents the agent from discovering old workspace directories.") - print_info("You can always rename it back if needed.") - print() - - if not auto_yes and not sys.stdin.isatty(): - print_info("Non-interactive session — skipping archival.") - print_info("Run later with: hermes claw cleanup") - return - - if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True): - try: - archive_path = _archive_directory(source_dir) - print_success(f"Archived: {source_dir} → {archive_path}") - print_info("The original directory has been renamed, not deleted.") - print_info(f"To undo: mv {archive_path} {source_dir}") - except OSError as e: - print_error(f"Could not archive: {e}") - print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration") - else: - print_info("Skipped. You can archive later with: hermes claw cleanup") + # Source directory is left untouched — archiving is not the migration + # tool's responsibility. Users who want to clean up can run + # 'hermes claw cleanup' separately. def _cmd_cleanup(args): """Archive leftover OpenClaw directories after migration. Scans for OpenClaw directories that still exist after migration and offers - to rename them to .pre-migration to prevent state fragmentation. + to rename them to .pre-migration to free disk space. """ dry_run = getattr(args, "dry_run", False) auto_yes = getattr(args, "yes", False) @@ -517,7 +468,7 @@ def _cmd_cleanup(args): if state_files: print() - print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW)) + print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW)) for path, desc in state_files[:8]: print(f" {desc}") if len(state_files) > 8: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5faa767a34c..011c9bca9b6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -50,6 +50,7 @@ "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", + "MATRIX_RECOVERY_KEY", }) import yaml @@ -354,6 +355,10 @@ def _ensure_hermes_home_managed(home: Path): # threshold before escalating to a full timeout. The warning fires # once per run and does not interrupt the agent. 0 = disable warning. "gateway_timeout_warning": 900, + # Periodic "still working" notification interval (seconds). + # Sends a status message every N seconds so the user knows the + # agent hasn't died during long tasks. 0 = disable notifications. + "gateway_notify_interval": 600, }, "terminal": { @@ -706,6 +711,14 @@ def _ensure_hermes_home_managed(home: Path): "backup_count": 3, # Number of rotated backup files to keep }, + # Network settings — workarounds for connectivity issues. + "network": { + # Force IPv4 connections. On servers with broken or unreachable IPv6, + # Python tries AAAA records first and hangs for the full TCP timeout + # before falling back to IPv4. Set to true to skip IPv6 entirely. + "force_ipv4": False, + }, + # Config schema version - bump this when adding new required fields "_config_version": 16, } @@ -1285,6 +1298,14 @@ def _ensure_hermes_home_managed(home: Path): "category": "messaging", "advanced": True, }, + "MATRIX_RECOVERY_KEY": { + "description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)", + "prompt": "Matrix recovery key", + "url": None, + "password": True, + "category": "messaging", + "advanced": True, + }, "BLUEBUBBLES_SERVER_URL": { "description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)", "prompt": "BlueBubbles server URL", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 908d8992a09..8cdb856c96f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2100,12 +2100,6 @@ def _setup_dingtalk(): _setup_standard_platform(dingtalk_platform) -def _setup_feishu(): - """Configure Feishu / Lark via the standard platform setup.""" - feishu_platform = next(p for p in _PLATFORMS if p["key"] == "feishu") - _setup_standard_platform(feishu_platform) - - def _setup_wecom(): """Configure WeCom (Enterprise WeChat) via the standard platform setup.""" wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom") @@ -2290,6 +2284,178 @@ def _setup_weixin(): print_info(f" User ID: {user_id}") +def _setup_feishu(): + """Interactive setup for Feishu / Lark — scan-to-create or manual credentials.""" + print() + print(color(" ─── 🪽 Feishu / Lark Setup ───", Colors.CYAN)) + + existing_app_id = get_env_value("FEISHU_APP_ID") + existing_secret = get_env_value("FEISHU_APP_SECRET") + if existing_app_id and existing_secret: + print() + print_success("Feishu / Lark is already configured.") + if not prompt_yes_no(" Reconfigure Feishu / Lark?", False): + return + + # ── Choose setup method ── + print() + method_choices = [ + "Scan QR code to create a new bot automatically (recommended)", + "Enter existing App ID and App Secret manually", + ] + method_idx = prompt_choice(" How would you like to set up Feishu / Lark?", method_choices, 0) + + credentials = None + used_qr = False + + if method_idx == 0: + # ── QR scan-to-create ── + try: + from gateway.platforms.feishu import qr_register + except Exception as exc: + print_error(f" Feishu / Lark onboard import failed: {exc}") + qr_register = None + + if qr_register is not None: + try: + credentials = qr_register() + except KeyboardInterrupt: + print() + print_warning(" Feishu / Lark setup cancelled.") + return + except Exception as exc: + print_warning(f" QR registration failed: {exc}") + if credentials: + used_qr = True + if not credentials: + print_info(" QR setup did not complete. Continuing with manual input.") + + # ── Manual credential input ── + if not credentials: + print() + print_info(" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)") + print_info(" Create an app, enable the Bot capability, and copy the credentials.") + print() + app_id = prompt(" App ID", password=False) + if not app_id: + print_warning(" Skipped — Feishu / Lark won't work without an App ID.") + return + app_secret = prompt(" App Secret", password=True) + if not app_secret: + print_warning(" Skipped — Feishu / Lark won't work without an App Secret.") + return + + domain_choices = ["feishu (China)", "lark (International)"] + domain_idx = prompt_choice(" Domain", domain_choices, 0) + domain = "lark" if domain_idx == 1 else "feishu" + + # Try to probe the bot with manual credentials + bot_name = None + try: + from gateway.platforms.feishu import probe_bot + bot_info = probe_bot(app_id, app_secret, domain) + if bot_info: + bot_name = bot_info.get("bot_name") + print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}") + else: + print_warning(" Could not verify bot connection. Credentials saved anyway.") + except Exception as exc: + print_warning(f" Credential verification skipped: {exc}") + + credentials = { + "app_id": app_id, + "app_secret": app_secret, + "domain": domain, + "open_id": None, + "bot_name": bot_name, + } + + # ── Save core credentials ── + app_id = credentials["app_id"] + app_secret = credentials["app_secret"] + domain = credentials.get("domain", "feishu") + open_id = credentials.get("open_id") + bot_name = credentials.get("bot_name") + + save_env_value("FEISHU_APP_ID", app_id) + save_env_value("FEISHU_APP_SECRET", app_secret) + save_env_value("FEISHU_DOMAIN", domain) + # Bot identity is resolved at runtime via _hydrate_bot_identity(). + + # ── Connection mode ── + if used_qr: + connection_mode = "websocket" + else: + print() + mode_choices = [ + "WebSocket (recommended — no public URL needed)", + "Webhook (requires a reachable HTTP endpoint)", + ] + mode_idx = prompt_choice(" Connection mode", mode_choices, 0) + connection_mode = "webhook" if mode_idx == 1 else "websocket" + if connection_mode == "webhook": + print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook") + print_info(" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH") + print_info(" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN") + save_env_value("FEISHU_CONNECTION_MODE", connection_mode) + + if bot_name: + print() + print_success(f" Bot created: {bot_name}") + + # ── DM security policy ── + print() + access_choices = [ + "Use DM pairing approval (recommended)", + "Allow all direct messages", + "Only allow listed user IDs", + ] + access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + if access_idx == 0: + save_env_value("FEISHU_ALLOW_ALL_USERS", "false") + save_env_value("FEISHU_ALLOWED_USERS", "") + print_success(" DM pairing enabled.") + print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + elif access_idx == 1: + save_env_value("FEISHU_ALLOW_ALL_USERS", "true") + save_env_value("FEISHU_ALLOWED_USERS", "") + print_warning(" Open DM access enabled for Feishu / Lark.") + else: + save_env_value("FEISHU_ALLOW_ALL_USERS", "false") + default_allow = open_id or "" + allowlist = prompt(" Allowed user IDs (comma-separated)", default_allow, password=False).replace(" ", "") + save_env_value("FEISHU_ALLOWED_USERS", allowlist) + print_success(" Allowlist saved.") + + # ── Group policy ── + print() + group_choices = [ + "Respond only when @mentioned in groups (recommended)", + "Disable group chats", + ] + group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0) + if group_idx == 0: + save_env_value("FEISHU_GROUP_POLICY", "open") + print_info(" Group chats enabled (bot must be @mentioned).") + else: + save_env_value("FEISHU_GROUP_POLICY", "disabled") + print_info(" Group chats disabled.") + + # ── Home channel ── + print() + home_channel = prompt(" Home chat ID (optional, for cron/notifications)", password=False) + if home_channel: + save_env_value("FEISHU_HOME_CHANNEL", home_channel) + print_success(f" Home channel set to {home_channel}") + + print() + print_success("🪽 Feishu / Lark configured!") + print_info(f" App ID: {app_id}") + print_info(f" Domain: {domain}") + if bot_name: + print_info(f" Bot: {bot_name}") + + def _setup_signal(): """Interactive setup for Signal messenger.""" import shutil @@ -2467,6 +2633,8 @@ def gateway_setup(): _setup_signal() elif platform["key"] == "weixin": _setup_weixin() + elif platform["key"] == "feishu": + _setup_feishu() else: _setup_standard_platform(platform) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 037c0a72feb..1e04008844c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -151,6 +151,18 @@ def _apply_profile_override() -> None: except Exception: pass # best-effort — don't crash the CLI if logging setup fails +# Apply IPv4 preference early, before any HTTP clients are created. +try: + from hermes_cli.config import load_config as _load_config_early + from hermes_constants import apply_ipv4_preference as _apply_ipv4 + _early_cfg = _load_config_early() + _net = _early_cfg.get("network", {}) + if isinstance(_net, dict) and _net.get("force_ipv4"): + _apply_ipv4(force=True) + del _early_cfg, _net +except Exception: + pass # best-effort — don't crash if config isn't available yet + import logging import time as _time from datetime import datetime @@ -1095,6 +1107,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: "base_url": base_url, "api_key": entry.get("api_key", ""), "model": entry.get("model", ""), + "api_mode": entry.get("api_mode", ""), } return custom_provider_map @@ -1943,6 +1956,12 @@ def _model_flow_named_custom(config, provider_info): model["base_url"] = base_url if api_key: model["api_key"] = api_key + # Apply api_mode from custom_providers entry, or clear stale value + custom_api_mode = provider_info.get("api_mode", "") + if custom_api_mode: + model["api_mode"] = custom_api_mode + else: + model.pop("api_mode", None) # let runtime auto-detect from URL save_config(cfg) deactivate_provider() @@ -2480,8 +2499,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): print() override = "" if override and base_url_env: - save_env_value(base_url_env, override) - effective_base = override + if not override.startswith(("http://", "https://")): + print(" Invalid URL — must start with http:// or https://. Keeping current value.") + else: + save_env_value(base_url_env, override) + effective_base = override # Model selection — resolution order: # 1. models.dev registry (cached, filtered for agentic/tool-capable models) @@ -3917,6 +3939,26 @@ def cmd_update(args): print() print("✓ Update complete!") + # Write exit code *before* the gateway restart attempt. + # When running as ``hermes update --gateway`` (spawned by the gateway's + # /update command), this process lives inside the gateway's systemd + # cgroup. ``systemctl restart hermes-gateway`` kills everything in the + # cgroup (KillMode=mixed → SIGKILL to remaining processes), including + # us and the wrapping bash shell. The shell never reaches its + # ``printf $status > .update_exit_code`` epilogue, so the exit-code + # marker file is never created. The new gateway's update watcher then + # polls for 30 minutes and sends a spurious timeout message. + # + # Writing the marker here — after git pull + pip install succeed but + # before we attempt the restart — ensures the new gateway sees it + # regardless of how we die. + if gateway_mode: + _exit_code_path = get_hermes_home() / ".update_exit_code" + try: + _exit_code_path.write_text("0") + except OSError: + pass + # Auto-restart ALL gateways after update. # The code update (git pull) is shared across all profiles, so every # running gateway needs restarting to pick up the new code. @@ -4201,18 +4243,24 @@ def cmd_profile(args): print(f' Add to your shell config (~/.bashrc or ~/.zshrc):') print(f' export PATH="$HOME/.local/bin:$PATH"') + # Profile dir for display + try: + profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home())) + except ValueError: + profile_dir_display = str(profile_dir) + # Next steps print(f"\nNext steps:") print(f" {name} setup Configure API keys and model") print(f" {name} chat Start chatting") print(f" {name} gateway start Start the messaging gateway") if clone or clone_all: - try: - profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home())) - except ValueError: - profile_dir_display = str(profile_dir) print(f"\n Edit {profile_dir_display}/.env for different API keys") print(f" Edit {profile_dir_display}/SOUL.md for different personality") + else: + print(f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first,") + print(f" or it will inherit keys from your shell environment.") + print(f" Edit {profile_dir_display}/SOUL.md to customize personality") print() except (ValueError, FileExistsError, FileNotFoundError) as e: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 273da087197..988983dbadd 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -839,8 +839,11 @@ def list_authenticated_providers( if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars): has_creds = True break - if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"): - # These use auth stores, not env vars — check for auth.json entries + # Check auth store and credential pool for non-env-var credentials. + # This applies to OAuth providers AND api_key providers that also + # support OAuth (e.g. anthropic supports both API key and Claude Code + # OAuth via external credential files). + if not has_creds: try: from hermes_cli.auth import _load_auth_store store = _load_auth_store() @@ -853,6 +856,38 @@ def list_authenticated_providers( has_creds = True except Exception as exc: logger.debug("Auth store check failed for %s: %s", pid, exc) + # Fallback: check the credential pool with full auto-seeding. + # This catches credentials that exist in external stores (e.g. + # Codex CLI ~/.codex/auth.json) which _seed_from_singletons() + # imports on demand but aren't in the raw auth.json yet. + if not has_creds: + try: + from agent.credential_pool import load_pool + pool = load_pool(hermes_slug) + if pool.has_credentials(): + has_creds = True + except Exception as exc: + logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) + # Fallback: check external credential files directly. + # The credential pool gates anthropic behind + # is_provider_explicitly_configured() to prevent auxiliary tasks + # from silently consuming Claude Code tokens (PR #4210). + # But the /model picker is discovery-oriented — we WANT to show + # providers the user can switch to, even if they aren't currently + # configured. + if not has_creds and hermes_slug == "anthropic": + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + read_hermes_oauth_credentials, + ) + hermes_creds = read_hermes_oauth_credentials() + cc_creds = read_claude_code_credentials() + if (hermes_creds and hermes_creds.get("accessToken")) or \ + (cc_creds and cc_creds.get("accessToken")): + has_creds = True + except Exception as exc: + logger.debug("Anthropic external creds check failed: %s", exc) if not has_creds: continue diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ae4146415ee..85777698325 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -546,6 +546,20 @@ def check_nous_free_tier() -> bool: } +def get_default_model_for_provider(provider: str) -> str: + """Return the default model for a provider, or empty string if unknown. + + Uses the first entry in _PROVIDER_MODELS as the default. This is the + model a user would be offered first in the ``hermes model`` picker. + + Used as a fallback when the user has configured a provider but never + selected a model (e.g. ``hermes auth add openai-codex`` without + ``hermes model``). + """ + models = _PROVIDER_MODELS.get(provider, []) + return models[0] if models else "" + + def _openrouter_model_is_free(pricing: Any) -> bool: """Return True when both prompt and completion pricing are zero.""" if not isinstance(pricing, dict): diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 6735ff0f047..1e9fcae0052 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -459,6 +459,16 @@ def create_profile( dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) + # Seed a default SOUL.md so the user has a file to customize immediately. + # Skipped when the profile already has one (from --clone / --clone-all). + soul_path = profile_dir / "SOUL.md" + if not soul_path.exists(): + try: + from hermes_cli.default_soul import DEFAULT_SOUL_MD + soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") + except Exception: + pass # best-effort — don't fail profile creation over this + return profile_dir diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py new file mode 100644 index 00000000000..bb9f9e60cd1 --- /dev/null +++ b/hermes_cli/tips.py @@ -0,0 +1,351 @@ +"""Random tips shown at CLI session start to help users discover features.""" + +import random +from typing import Optional + +# --------------------------------------------------------------------------- +# Tip corpus — one-liners covering slash commands, CLI flags, config, +# keybindings, tools, gateway, skills, profiles, and workflow tricks. +# --------------------------------------------------------------------------- + +TIPS = [ + # --- Slash Commands --- + "/btw asks a quick side question without tools or history — great for clarifications.", + "/background runs a task in a separate session while your current one stays free.", + "/branch forks the current session so you can explore a different direction without losing progress.", + "/compress manually compresses conversation context when things get long.", + "/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.", + "/rollback diff 2 previews what changed since checkpoint 2 without restoring anything.", + "/rollback 2 src/file.py restores a single file from a specific checkpoint.", + "/title \"my project\" names your session — resume it later with /resume or hermes -c.", + "/resume picks up where you left off in a previously named session.", + "/queue queues a message for the next turn without interrupting the current one.", + "/undo removes the last user/assistant exchange from the conversation.", + "/retry resends your last message — useful when the agent's response wasn't quite right.", + "/verbose cycles tool progress display: off → new → all → verbose.", + "/reasoning high increases the model's thinking depth. /reasoning show displays the reasoning.", + "/fast toggles priority processing for faster API responses (provider-dependent).", + "/yolo skips all dangerous command approval prompts for the rest of the session.", + "/model lets you switch models mid-session — try /model sonnet or /model gpt-5.", + "/model --global changes your default model permanently.", + "/personality pirate sets a fun personality — 14 built-in options from kawaii to shakespeare.", + "/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.", + "/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.", + "/tools disable browser temporarily removes browser tools for the current session.", + "/browser connect attaches browser tools to your running Chrome instance via CDP.", + "/plugins lists installed plugins and their status.", + "/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.", + "/reload-mcp hot-reloads MCP server configuration without restarting.", + "/usage shows token usage, cost breakdown, and session duration.", + "/insights shows usage analytics for the last 30 days.", + "/paste checks your clipboard for an image and attaches it to your next message.", + "/profile shows which profile is active and its home directory.", + "/config shows your current configuration at a glance.", + "/stop kills all running background processes spawned by the agent.", + + # --- @ Context References --- + "@file:path/to/file.py injects file contents directly into your message.", + "@file:main.py:10-50 injects only lines 10-50 of a file.", + "@folder:src/ injects a directory tree listing.", + "@diff injects your unstaged git changes into the message.", + "@staged injects your staged git changes (git diff --staged).", + "@git:5 injects the last 5 commits with full patches.", + "@url:https://example.com fetches and injects a web page's content.", + "Typing @ triggers filesystem path completion — navigate to any file interactively.", + "Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"", + + # --- Keybindings --- + "Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.", + "Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.", + "Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.", + "Tab accepts auto-suggestion ghost text or autocompletes slash commands.", + "Type a new message while the agent is working to interrupt and redirect it.", + "Alt+V pastes an image from your clipboard into the conversation.", + "Pasting 5+ lines auto-saves to a file and inserts a compact reference instead.", + + # --- CLI Flags --- + "hermes -c resumes your most recent CLI session. hermes -c \"project name\" resumes by title.", + "hermes -w creates an isolated git worktree — perfect for parallel agent workflows.", + "hermes -w -q \"Fix issue #42\" combines worktree isolation with a one-shot query.", + "hermes chat -t web,terminal enables only specific toolsets for a focused session.", + "hermes chat -s github-pr-workflow preloads a skill at launch.", + "hermes chat -q \"query\" runs a single non-interactive query and exits.", + "hermes chat --max-turns 200 overrides the default 90-iteration limit per turn.", + "hermes chat --checkpoints enables filesystem snapshots before every destructive file change.", + "hermes --yolo bypasses all dangerous command approval prompts for the entire session.", + "hermes chat --source telegram tags the session for filtering in hermes sessions list.", + "hermes -p work chat runs under a specific profile without changing your default.", + + # --- CLI Subcommands --- + "hermes doctor --fix diagnoses and auto-repairs config and dependency issues.", + "hermes dump outputs a compact setup summary — great for bug reports.", + "hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.", + "hermes config edit opens config.yaml in your default editor.", + "hermes config check scans for missing or stale configuration options.", + "hermes sessions browse opens an interactive session picker with search.", + "hermes sessions stats shows session counts by platform and database size.", + "hermes sessions prune --older-than 30 cleans up old sessions.", + "hermes skills search react --source skills-sh searches the skills.sh public directory.", + "hermes skills check scans installed hub skills for upstream updates.", + "hermes skills tap add myorg/skills-repo adds a custom GitHub skill source.", + "hermes skills snapshot export setup.json exports your skill configuration for backup or sharing.", + "hermes mcp add github --command npx adds MCP servers from the command line.", + "hermes mcp serve runs Hermes itself as an MCP server for other agents.", + "hermes auth add lets you add multiple API keys for credential pool rotation.", + "hermes completion bash >> ~/.bashrc enables tab completion for all commands and profiles.", + "hermes logs -f follows agent.log in real time. --level WARNING --since 1h filters output.", + "hermes backup creates a zip backup of your entire Hermes home directory.", + "hermes profile create coder creates an isolated profile that becomes its own command.", + "hermes profile create work --clone copies your current config and keys to a new profile.", + "hermes update syncs new bundled skills to ALL profiles automatically.", + "hermes gateway install sets up Hermes as a system service (systemd/launchd).", + "hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).", + "hermes webhook subscribe creates event-driven webhook routes with HMAC validation.", + + # --- Configuration --- + "Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.", + "Set display.streaming: true to see tokens appear in real time as the model generates.", + "Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.", + "Set display.compact: true to reduce whitespace in output for denser information.", + "Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.", + "Set display.resume_display: minimal to skip the full conversation recap on session resume.", + "Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).", + "Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.", + "Set file_read_max_chars: 200000 to increase the max content per read_file call.", + "Set approvals.mode: smart to let an LLM auto-approve safe commands and auto-deny dangerous ones.", + "Set fallback_model in config.yaml to automatically fail over to a backup provider.", + "Set privacy.redact_pii: true to hash user IDs and phone numbers before sending to the LLM.", + "Set browser.record_sessions: true to auto-record browser sessions as WebM videos.", + "Set worktree: true in config.yaml to always create a git worktree (same as hermes -w).", + "Set security.website_blocklist.enabled: true to block specific domains from web tools.", + "Set cron.wrap_response: false to deliver raw agent output without the cron header/footer.", + "HERMES_TIMEZONE overrides the server timezone with any IANA timezone string.", + "Environment variable substitution works in config.yaml: use ${VAR_NAME} syntax.", + "Quick commands in config.yaml run shell commands instantly with zero token usage.", + "Custom personalities can be defined in config.yaml under agent.personalities.", + "provider_routing controls OpenRouter provider sorting, whitelisting, and blacklisting.", + + # --- Tools & Capabilities --- + "execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.", + "delegate_task spawns up to 3 concurrent sub-agents with isolated contexts for parallel work.", + "web_extract works on PDF URLs — pass any PDF link and it converts to markdown.", + "search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.", + "patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.", + "patch supports V4A format for bulk multi-file edits in a single call.", + "read_file suggests similar filenames when a file isn't found.", + "read_file auto-deduplicates — re-reading an unchanged file returns a lightweight stub.", + "browser_vision takes a screenshot and analyzes it with AI — works for CAPTCHAs and visual content.", + "browser_console can evaluate JavaScript expressions in the page context.", + "image_generate creates images with FLUX 2 Pro and automatic 2x upscaling.", + "text_to_speech converts text to audio — plays as voice bubbles on Telegram.", + "send_message can reach any connected messaging platform from within a session.", + "The todo tool helps the agent track complex multi-step tasks during a session.", + "session_search performs full-text search across ALL past conversations.", + "The agent automatically saves preferences, corrections, and environment facts to memory.", + "mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.", + "Terminal commands support background mode with notify_on_complete for long-running tasks.", + "Terminal background processes support watch_patterns to alert on specific output lines.", + "The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.", + + # --- Profiles --- + "Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.", + "Profile names become shell commands — 'hermes profile create coder' creates the 'coder' command.", + "hermes profile export coder -o backup.tar.gz creates a portable profile archive.", + "If two profiles accidentally share a bot token, the second gateway is blocked with a clear error.", + + # --- Sessions --- + "Sessions auto-generate descriptive titles after the first exchange — no manual naming needed.", + "Session titles support lineage: \"my project\" → \"my project #2\" → \"my project #3\".", + "When exiting, Hermes prints a resume command with session ID and stats.", + "hermes sessions export backup.jsonl exports all sessions for backup or analysis.", + "hermes -r SESSION_ID resumes any specific past session by its ID.", + + # --- Memory --- + "Memory is a frozen snapshot — changes appear in the system prompt only at next session start.", + "Memory entries are automatically scanned for prompt injection and exfiltration patterns.", + "The agent has two memory stores: personal notes (~2200 chars) and user profile (~1375 chars).", + "Corrections you give the agent (\"no, do it this way\") are often auto-saved to memory.", + + # --- Skills --- + "Over 80 bundled skills covering github, creative, mlops, productivity, research, and more.", + "Every installed skill automatically becomes a slash command — type / to see them all.", + "hermes skills install official/security/1password installs optional skills from the repo.", + "Skills can restrict to specific OS platforms — some only load on macOS or Linux.", + "skills.external_dirs in config.yaml lets you load skills from custom directories.", + "The agent can create its own skills as procedural memory using skill_manage.", + "The plan skill saves markdown plans under .hermes/plans/ in the active workspace.", + + # --- Cron & Scheduling --- + "Cron jobs can attach skills: hermes cron add --skill blogwatcher \"Check for new posts\".", + "Cron delivery targets include telegram, discord, slack, email, sms, and 12+ more platforms.", + "If a cron response starts with [SILENT], delivery is suppressed — useful for monitoring-only jobs.", + "Cron supports relative delays (30m), intervals (every 2h), cron expressions, and ISO timestamps.", + "Cron jobs run in completely fresh agent sessions — prompts must be self-contained.", + + # --- Voice --- + "Voice mode works with zero API keys if faster-whisper is installed (free local speech-to-text).", + "Five TTS providers available: Edge TTS (free), ElevenLabs, OpenAI, NeuTTS (free local), MiniMax.", + "/voice on enables voice mode in the CLI. Ctrl+B toggles push-to-talk recording.", + "Streaming TTS plays sentences as they generate — you don't wait for the full response.", + "Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.", + + # --- Gateway & Messaging --- + "Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.", + "hermes gateway install sets it up as a system service that starts on boot.", + "DingTalk uses Stream Mode — no webhooks or public URL needed.", + "BlueBubbles brings iMessage to Hermes via a local macOS server.", + "Webhook routes support HMAC validation, rate limiting, and event filtering.", + "The API server exposes an OpenAI-compatible endpoint compatible with Open WebUI and LibreChat.", + "Discord voice channel mode: the bot joins VC, transcribes speech, and talks back.", + "group_sessions_per_user: true gives each person their own session in group chats.", + "/sethome marks a chat as the home channel for cron job deliveries.", + "The gateway supports inactivity-based timeouts — active agents can run indefinitely.", + + # --- Security --- + "Dangerous command approval has 4 tiers: once, session, always (permanent allowlist), deny.", + "Smart approval mode uses an LLM to auto-approve safe commands and flag dangerous ones.", + "SSRF protection blocks private networks, loopback, link-local, and cloud metadata addresses.", + "Tirith pre-exec scanning detects homograph URL spoofing and pipe-to-interpreter patterns.", + "MCP subprocesses receive a filtered environment — only safe system vars pass through.", + "Context files (.hermes.md, AGENTS.md) are security-scanned for prompt injection before loading.", + "command_allowlist in config.yaml permanently approves specific shell command patterns.", + + # --- Context & Compression --- + "Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.", + "The status bar turns yellow, then orange, then red as context fills up.", + "SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.", + "Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).", + "Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.", + "Context files are capped at 20,000 characters with smart head/tail truncation.", + + # --- Browser --- + "Five browser providers: local Chromium, Browserbase, Browser Use, Camofox, and Firecrawl.", + "Camofox is an anti-detection browser — Firefox fork with C++ fingerprint spoofing.", + "browser_navigate returns a page snapshot automatically — no need to call browser_snapshot after.", + "browser_vision with annotate=true overlays numbered labels on interactive elements.", + + # --- MCP --- + "MCP servers are configured in config.yaml — both stdio and HTTP transports supported.", + "Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.", + "MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.", + "MCP OAuth support: auth: oauth enables browser-based authorization with PKCE.", + + # --- Checkpoints & Rollback --- + "Checkpoints have zero overhead when no files are modified — enabled by default.", + "A pre-rollback snapshot is saved automatically so you can undo the undo.", + "/rollback also undoes the conversation turn, so the agent doesn't remember rolled-back changes.", + "Checkpoints use shadow repos in ~/.hermes/checkpoints/ — your project's .git is never touched.", + + # --- Batch & Data --- + "batch_runner.py processes hundreds of prompts in parallel for training data generation.", + "hermes chat -Q enables quiet mode for programmatic use — suppresses banner and spinner.", + "Trajectory saving (--save-trajectories) captures full tool-use traces for model training.", + + # --- Plugins --- + "Three plugin types: general (tools/hooks), memory providers, and context engines.", + "hermes plugins install owner/repo installs plugins directly from GitHub.", + "8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.", + "Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.", + + # --- Miscellaneous --- + "Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.", + "The agent auto-generates session titles in a background thread — zero latency impact.", + "Smart model routing can auto-route simple queries to a cheaper model.", + "Slash commands support prefix matching: /h resolves to /help, /mod to /model.", + "Dragging a file path into the terminal auto-attaches images or sends as context.", + ".worktreeinclude in your repo root lists gitignored files to copy into worktrees.", + "hermes acp runs Hermes as an ACP server for VS Code, Zed, and JetBrains integration.", + "Custom providers: save named endpoints in config.yaml under custom_providers.", + "HERMES_EPHEMERAL_SYSTEM_PROMPT injects a system prompt that's never persisted to history.", + "credential_pool_strategies supports fill_first, round_robin, least_used, and random rotation.", + "hermes login supports OAuth-based auth for Nous and OpenAI Codex providers.", + "The API server supports both Chat Completions and Responses API with server-side state.", + "tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.", + "hermes status --deep runs deeper diagnostic checks across all components.", + + # --- Hidden Gems & Power-User Tricks --- + "BOOT.md at ~/.hermes/BOOT.md runs automatically on every gateway start — use it for startup checks.", + "Cron jobs can attach a Python script (--script) whose stdout is injected into the prompt as context.", + "Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.", + "prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.", + "SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.", + "SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.", + "/compress allocates 60-70% of the summary budget to your topic and aggressively trims the rest.", + "On second+ compression, the compressor updates the previous summary instead of starting from scratch.", + "Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.", + "network.force_ipv4: true in config.yaml fixes hangs on servers with broken IPv6 — monkey-patches socket.", + "The terminal tool annotates common exit codes: grep returning 1 = 'No matches found (not an error)'.", + "Failed foreground terminal commands auto-retry up to 3 times with exponential backoff (2s, 4s, 8s).", + "Bare sudo commands are auto-rewritten to pipe SUDO_PASSWORD from .env — no interactive prompt needed.", + "execute_code has built-in helpers: json_parse() for tolerant parsing, shell_quote(), and retry() with backoff.", + "execute_code's 7 sandbox tools (web_search, terminal, read/write/search/patch) use RPC — never enter context.", + "Reading the same file region 3+ times triggers a warning. At 4+, it's hard-blocked to prevent loops.", + "write_file and patch detect if a file was externally modified since the last read and warn about staleness.", + "V4A patch format supports Add File, Delete File, and Move File directives — not just Update.", + "MCP servers can request LLM completions back via sampling — the agent becomes a tool for the server.", + "MCP servers send notifications/tools/list_changed to trigger automatic tool re-registration without restart.", + "delegate_task with acp_command: 'claude' spawns Claude Code as a child agent from any platform.", + "Delegation has a heartbeat thread — child activity propagates to the parent, preventing gateway timeouts.", + "When a provider returns HTTP 402 (payment required), the auxiliary client auto-falls back to the next one.", + "agent.tool_use_enforcement steers models that describe actions instead of calling tools — auto for GPT/Codex.", + "agent.restart_drain_timeout (default 60s) lets running agents finish before a gateway restart takes effect.", + "The gateway caches AIAgent instances per session — destroying this cache breaks Anthropic prompt caching.", + "Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.", + "The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.", + "Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.", + "Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.", + "HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.", + "Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.", + "Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.", + "Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.", + "BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.", + "BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).", + "The CLI auto-switches to compact mode in terminals narrower than 80 columns.", + "Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).", + "Per-task delegation model: delegation.model and delegation.provider in config route subagents to cheaper models.", + "delegation.reasoning_effort independently controls thinking depth for subagents.", + "display.platforms in config.yaml allows per-platform display overrides: {telegram: {tool_progress: all}}.", + "human_delay.mode in config simulates human typing speed — configurable min_ms/max_ms range.", + "Config version migrations run automatically on load — new config keys appear without manual intervention.", + "GPT and Codex models get special system prompt guidance for tool discipline and mandatory tool use.", + "Gemini models get tailored directives for absolute paths, parallel tool calls, and non-interactive commands.", + "context.engine in config.yaml can be set to a plugin name for alternative context management strategies.", + "Browser pages over 8000 tokens are auto-summarized by the auxiliary LLM before returning to the agent.", + "The compressor does a cheap pre-pass: tool outputs over 200 chars are replaced with placeholders before the LLM runs.", + "When compression fails, further attempts are paused for 10 minutes to avoid API hammering.", + "Long dangerous commands (>70 chars) get a 'view' option in the approval prompt to see the full text first.", + "Audio level visualization shows ▁▂▃▄▅▆▇ bars during voice recording based on microphone RMS levels.", + "Profile names cannot collide with existing PATH binaries — 'hermes profile create ls' would be rejected.", + "hermes profile create backup --clone-all copies everything (config, keys, SOUL.md, memories, skills, sessions).", + "The voice record key is configurable via voice.record_key in config.yaml — not just Ctrl+B.", + ".cursorrules and .cursor/rules/*.mdc files are auto-detected and loaded as project context.", + "Context files support 10+ prompt injection patterns — invisible Unicode, 'ignore instructions', exfil attempts.", + "GPT-5 and Codex use 'developer' role instead of 'system' in the message format.", + "Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.", + "The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.", + "Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.", + "hermes claw migrate --dry-run previews OpenClaw migration without writing anything.", + "File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.", + "Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.", + "In interrupt mode, slash commands typed during agent execution bypass interrupt logic and run immediately.", + "HERMES_DEV=1 bypasses container mode detection for local development.", + "Each MCP server gets its own toolset (mcp-servername) that can be toggled independently via hermes tools.", + "MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.", + "Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.", + "The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.", +] + + +def get_random_tip(exclude_recent: int = 0) -> str: + """Return a random tip string. + + Args: + exclude_recent: not used currently; reserved for future + deduplication across sessions. + """ + return random.choice(TIPS) + + +def get_tip_count() -> int: + """Return the total number of tips available.""" + return len(TIPS) diff --git a/hermes_constants.py b/hermes_constants.py index 85955d5482f..40b4da5693f 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -216,6 +216,51 @@ def get_env_path() -> Path: return get_hermes_home() / ".env" +# ─── Network Preferences ───────────────────────────────────────────────────── + + +def apply_ipv4_preference(force: bool = False) -> None: + """Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections. + + On servers with broken or unreachable IPv6, Python tries AAAA records + first and hangs for the full TCP timeout before falling back to IPv4. + This affects httpx, requests, urllib, the OpenAI SDK — everything that + uses ``socket.getaddrinfo``. + + When *force* is True, patches ``getaddrinfo`` so that calls with + ``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead, + skipping IPv6 entirely. If no A record exists, falls back to the + original unfiltered resolution so pure-IPv6 hosts still work. + + Safe to call multiple times — only patches once. + Set ``network.force_ipv4: true`` in ``config.yaml`` to enable. + """ + if not force: + return + + import socket + + # Guard against double-patching + if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False): + return + + _original_getaddrinfo = socket.getaddrinfo + + def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + if family == 0: # AF_UNSPEC — caller didn't request a specific family + try: + return _original_getaddrinfo( + host, port, socket.AF_INET, type, proto, flags + ) + except socket.gaierror: + # No A record — fall back to full resolution (pure-IPv6 hosts) + return _original_getaddrinfo(host, port, family, type, proto, flags) + return _original_getaddrinfo(host, port, family, type, proto, flags) + + _ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined] + socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment] + + OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" 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 759b798a56b..9b58eab5921 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -376,6 +376,24 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]: return dest +# ── Brand rewriting ───────────────────────────────────────── +# Replace OpenClaw brand names with Hermes in migrated text so that +# memory entries, user profiles, SOUL.md, and workspace instructions +# read as self-referential to the new agent identity. +_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [ + (re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'), + (re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'), + (re.compile(r'\bMoltBot\b', re.IGNORECASE), 'Hermes'), +] + + +def rebrand_text(text: str) -> str: + """Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes.""" + for pattern, replacement in _REBRAND_PATTERNS: + text = pattern.sub(replacement, text) + return text + + def parse_existing_memory_entries(path: Path) -> List[str]: if not path.exists(): return [] @@ -782,12 +800,13 @@ def write_overflow_entries(self, kind: str, entries: Sequence[str]) -> Optional[ path.write_text("\n".join(entries) + "\n", encoding="utf-8") return path - def copy_file(self, source: Path, destination: Path, kind: str) -> None: + def copy_file(self, source: Path, destination: Path, kind: str, + transform: Optional[Any] = None) -> None: if not source or not source.exists(): return if destination.exists(): - if sha256_file(source) == sha256_file(destination): + if not transform and sha256_file(source) == sha256_file(destination): self.record(kind, source, destination, "skipped", "Target already matches source") return if not self.overwrite: @@ -797,7 +816,13 @@ def copy_file(self, source: Path, destination: Path, kind: str) -> None: if self.execute: backup_path = self.maybe_backup(destination) ensure_parent(destination) - shutil.copy2(source, destination) + if transform: + content = read_text(source) + content = transform(content) + destination.write_text(content, encoding="utf-8") + shutil.copystat(source, destination) + else: + shutil.copy2(source, destination) self.record(kind, source, destination, "migrated", backup=str(backup_path) if backup_path else None) else: self.record(kind, source, destination, "migrated", "Would copy") @@ -807,7 +832,7 @@ def migrate_soul(self) -> None: if not source: self.record("soul", None, self.target_root / "SOUL.md", "skipped", "No OpenClaw SOUL.md found") return - self.copy_file(source, self.target_root / "SOUL.md", kind="soul") + self.copy_file(source, self.target_root / "SOUL.md", kind="soul", transform=rebrand_text) def migrate_workspace_agents(self) -> None: source = self.source_candidate( @@ -821,7 +846,7 @@ def migrate_workspace_agents(self) -> None: self.record("workspace-agents", source, None, "skipped", "No workspace target was provided") return destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME - self.copy_file(source, destination, kind="workspace-agents") + self.copy_file(source, destination, kind="workspace-agents", transform=rebrand_text) def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None: if not source or not source.exists(): @@ -832,6 +857,7 @@ def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, if not incoming: self.record(kind, source, destination, "skipped", "No importable entries found") return + incoming = [rebrand_text(entry) for entry in incoming] existing = parse_existing_memory_entries(destination) merged, stats, overflowed = merge_entries(existing, incoming, limit) @@ -927,7 +953,7 @@ def migrate_command_allowlist(self) -> None: def load_openclaw_config(self) -> Dict[str, Any]: # Check current name and legacy config filenames - for name in ("openclaw.json", "clawdbot.json", "moldbot.json"): + for name in ("openclaw.json", "clawdbot.json", "moltbot.json"): config_path = self.source_root / name if config_path.exists(): try: @@ -997,7 +1023,17 @@ def migrate_messaging_settings(self, config: Optional[Dict[str, Any]] = None) -> .get("workspace") ) if isinstance(workspace, str) and workspace.strip(): - additions["MESSAGING_CWD"] = workspace.strip() + ws_path = workspace.strip() + # Skip if the workspace points inside the OpenClaw source directory — + # that path will be stale after migration and would cause the Hermes + # gateway to use the old OpenClaw workspace as its cwd, picking up + # OpenClaw's AGENTS.md, MEMORY.md, etc. + try: + inside_source = Path(ws_path).resolve().is_relative_to(self.source_root.resolve()) + except (ValueError, OSError): + inside_source = False + if not inside_source: + additions["MESSAGING_CWD"] = ws_path allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json" if allowlist_path.exists(): @@ -1543,6 +1579,7 @@ def migrate_daily_memory(self) -> None: if not all_incoming: self.record("daily-memory", source_dir, destination, "skipped", "No importable entries found in daily memory files") return + all_incoming = [rebrand_text(entry) for entry in all_incoming] existing = parse_existing_memory_entries(destination) merged, stats, overflowed = merge_entries(existing, all_incoming, self.memory_limit) diff --git a/run_agent.py b/run_agent.py index b2303545425..360ef05177a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -94,7 +94,7 @@ from agent.context_compressor import ContextCompressor from agent.subdirectory_hints import SubdirectoryHintTracker from agent.prompt_caching import apply_anthropic_cache_control -from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE +from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE from agent.usage_pricing import estimate_usage_cost, normalize_usage from agent.display import ( KawaiiSpinner, build_tool_preview as _build_tool_preview, @@ -1307,6 +1307,7 @@ def __init__( api_key=getattr(self, "api_key", ""), config_context_length=_config_context_length, provider=self.provider, + api_mode=self.api_mode, ) self.compression_enabled = compression_enabled @@ -1563,6 +1564,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod base_url=self.base_url, api_key=getattr(self, "api_key", ""), provider=self.provider, + api_mode=self.api_mode, ) # ── Invalidate cached system prompt so it rebuilds next turn ── @@ -1696,6 +1698,16 @@ def _emit_status(self, message: str) -> None: except Exception: logger.debug("status_callback error in _emit_status", exc_info=True) + def _current_main_runtime(self) -> Dict[str, str]: + """Return the live main runtime for session-scoped auxiliary routing.""" + return { + "model": getattr(self, "model", "") or "", + "provider": getattr(self, "provider", "") or "", + "base_url": getattr(self, "base_url", "") or "", + "api_key": getattr(self, "api_key", "") or "", + "api_mode": getattr(self, "api_mode", "") or "", + } + def _check_compression_model_feasibility(self) -> None: """Warn at session start if the auxiliary compression model's context window is smaller than the main model's compression threshold. @@ -1716,7 +1728,10 @@ def _check_compression_model_feasibility(self) -> None: from agent.auxiliary_client import get_text_auxiliary_client from agent.model_metadata import get_model_context_length - client, aux_model = get_text_auxiliary_client("compression") + client, aux_model = get_text_auxiliary_client( + "compression", + main_runtime=self._current_main_runtime(), + ) if client is None or not aux_model: msg = ( "⚠ No auxiliary LLM provider configured — context " @@ -1857,12 +1872,13 @@ def _strip_think_blocks(self, content: str) -> str: if not content: return "" # Strip all reasoning tag variants: , , , - # , + # , , (Gemma 4) content = re.sub(r'.*?', '', content, flags=re.DOTALL) content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) content = re.sub(r'.*?', '', content, flags=re.DOTALL) content = re.sub(r'.*?', '', content, flags=re.DOTALL) - content = re.sub(r'\s*', '', content, flags=re.IGNORECASE) + content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) + content = re.sub(r'\s*', '', content, flags=re.IGNORECASE) return content def _looks_like_codex_intermediate_ack( @@ -3178,6 +3194,12 @@ def _build_system_prompt(self, system_message: str = None) -> str: f"not on any model name returned by the API." ) + # Environment hints (WSL, Termux, etc.) — tell the agent about the + # execution environment so it can translate paths and adapt behavior. + _env_hints = build_environment_hints() + if _env_hints: + prompt_parts.append(_env_hints) + platform_key = (self.platform or "").lower().strip() if platform_key in PLATFORM_HINTS: prompt_parts.append(PLATFORM_HINTS[platform_key]) @@ -8203,7 +8225,8 @@ def _stop_spinner(): if self.thinking_callback: self.thinking_callback("") - # This is often rate limiting or provider returning malformed response + # Invalid response — could be rate limiting, provider timeout, + # upstream server error, or malformed response. retry_count += 1 # Eager fallback: empty/malformed responses are a common @@ -8239,11 +8262,44 @@ def _stop_spinner(): if self.verbose_logging: logging.debug(f"Response attributes for invalid response: {resp_attrs}") + # Extract error code from response for contextual diagnostics + _resp_error_code = None + if response and hasattr(response, 'error') and response.error: + _code_raw = getattr(response.error, 'code', None) + if _code_raw is None and isinstance(response.error, dict): + _code_raw = response.error.get('code') + if _code_raw is not None: + try: + _resp_error_code = int(_code_raw) + except (TypeError, ValueError): + pass + + # Build a human-readable failure hint from the error code + # and response time, instead of always assuming rate limiting. + if _resp_error_code == 524: + _failure_hint = f"upstream provider timed out (Cloudflare 524, {api_duration:.0f}s)" + elif _resp_error_code == 504: + _failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)" + elif _resp_error_code == 429: + _failure_hint = f"rate limited by upstream provider (429)" + elif _resp_error_code in (500, 502): + _failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)" + elif _resp_error_code in (503, 529): + _failure_hint = f"upstream provider overloaded ({_resp_error_code})" + elif _resp_error_code is not None: + _failure_hint = f"upstream error (code {_resp_error_code}, {api_duration:.0f}s)" + elif api_duration < 10: + _failure_hint = f"fast response ({api_duration:.1f}s) — likely rate limited" + elif api_duration > 60: + _failure_hint = f"slow response ({api_duration:.0f}s) — likely upstream timeout" + else: + _failure_hint = f"response time {api_duration:.1f}s" + self._vprint(f"{self.log_prefix}⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}", force=True) self._vprint(f"{self.log_prefix} 🏢 Provider: {provider_name}", force=True) cleaned_provider_error = self._clean_error_message(error_msg) self._vprint(f"{self.log_prefix} 📝 Provider message: {cleaned_provider_error}", force=True) - self._vprint(f"{self.log_prefix} ⏱️ Response time: {api_duration:.2f}s (fast response often indicates rate limiting)", force=True) + self._vprint(f"{self.log_prefix} ⏱️ {_failure_hint}", force=True) if retry_count >= max_retries: # Try fallback before giving up @@ -8260,14 +8316,13 @@ def _stop_spinner(): "messages": messages, "completed": False, "api_calls": api_call_count, - "error": "Invalid API response shape. Likely rate limited or malformed provider response.", + "error": f"Invalid API response after {max_retries} retries: {_failure_hint}", "failed": True # Mark as failure for filtering } - # Longer backoff for rate limiting (likely cause of None choices) - # Jittered exponential: 5s base, 120s cap + random jitter + # Backoff before retry — jittered exponential: 5s base, 120s cap wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0) - self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...", force=True) + self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...", force=True) logging.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}") # Sleep in small increments to stay responsive to interrupts @@ -8278,7 +8333,7 @@ def _stop_spinner(): self._persist_session(messages, conversation_history) self.clear_interrupt() return { - "final_response": f"Operation interrupted: retrying API call after rate limit (retry {retry_count}/{max_retries}).", + "final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).", "messages": messages, "api_calls": api_call_count, "completed": False, diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index a38b62568a8..77004c4e107 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -971,6 +971,74 @@ def test_task_without_override_uses_auto(self, monkeypatch): client, model = get_text_auxiliary_client("compression") assert model == "google/gemini-3-flash-preview" # auto → OpenRouter + def test_resolve_auto_prefers_live_main_runtime_over_persisted_config(self, monkeypatch, tmp_path): + """Session-only live model switches should override persisted config for auto routing.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + """model: + default: glm-5.1 + provider: opencode-go +compression: + summary_provider: auto +""" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + calls = [] + + def _fake_resolve(provider, model=None, *args, **kwargs): + calls.append((provider, model, kwargs)) + return MagicMock(), model or "resolved-model" + + with patch("agent.auxiliary_client.resolve_provider_client", side_effect=_fake_resolve): + client, model = _resolve_auto( + main_runtime={ + "provider": "openai-codex", + "model": "gpt-5.4", + "api_mode": "codex_responses", + } + ) + + assert client is not None + assert model == "gpt-5.4" + assert calls[0][0] == "openai-codex" + assert calls[0][1] == "gpt-5.4" + assert calls[0][2]["api_mode"] == "codex_responses" + + def test_explicit_compression_pin_still_wins_over_live_main_runtime(self, monkeypatch, tmp_path): + """Task-level compression config should beat a live session override.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + """auxiliary: + compression: + provider: openrouter + model: google/gemini-3-flash-preview +model: + default: glm-5.1 + provider: opencode-go +""" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(MagicMock(), "google/gemini-3-flash-preview")) as mock_resolve: + client, model = get_text_auxiliary_client( + "compression", + main_runtime={ + "provider": "openai-codex", + "model": "gpt-5.4", + }, + ) + + assert client is not None + assert model == "google/gemini-3-flash-preview" + assert mock_resolve.call_args.args[0] == "openrouter" + assert mock_resolve.call_args.kwargs["main_runtime"] == { + "provider": "openai-codex", + "model": "gpt-5.4", + } + def test_compression_summary_base_url_from_config(self, monkeypatch, tmp_path): """compression.summary_base_url should produce a custom-endpoint client.""" hermes_home = tmp_path / "hermes" @@ -1560,3 +1628,74 @@ def test_warning_only_fires_once(self, monkeypatch, caplog): assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \ "Warning should not fire a second time" + + +# --------------------------------------------------------------------------- +# Anthropic-compatible image block conversion +# --------------------------------------------------------------------------- + +class TestAnthropicCompatImageConversion: + """Tests for _is_anthropic_compat_endpoint and _convert_openai_images_to_anthropic.""" + + def test_known_providers_detected(self): + from agent.auxiliary_client import _is_anthropic_compat_endpoint + assert _is_anthropic_compat_endpoint("minimax", "") + assert _is_anthropic_compat_endpoint("minimax-cn", "") + + def test_openrouter_not_detected(self): + from agent.auxiliary_client import _is_anthropic_compat_endpoint + assert not _is_anthropic_compat_endpoint("openrouter", "") + assert not _is_anthropic_compat_endpoint("anthropic", "") + + def test_url_based_detection(self): + from agent.auxiliary_client import _is_anthropic_compat_endpoint + assert _is_anthropic_compat_endpoint("custom", "https://api.minimax.io/anthropic") + assert _is_anthropic_compat_endpoint("custom", "https://example.com/anthropic/v1") + assert not _is_anthropic_compat_endpoint("custom", "https://api.openai.com/v1") + + def test_base64_image_converted(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR="}} + ] + }] + result = _convert_openai_images_to_anthropic(messages) + img_block = result[0]["content"][1] + assert img_block["type"] == "image" + assert img_block["source"]["type"] == "base64" + assert img_block["source"]["media_type"] == "image/png" + assert img_block["source"]["data"] == "iVBOR=" + + def test_url_image_converted(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}} + ] + }] + result = _convert_openai_images_to_anthropic(messages) + img_block = result[0]["content"][0] + assert img_block["type"] == "image" + assert img_block["source"]["type"] == "url" + assert img_block["source"]["url"] == "https://example.com/img.jpg" + + def test_text_only_messages_unchanged(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{"role": "user", "content": "Hello"}] + result = _convert_openai_images_to_anthropic(messages) + assert result[0] is messages[0] # same object, not copied + + def test_jpeg_media_type_parsed(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/="}} + ] + }] + result = _convert_openai_images_to_anthropic(messages) + assert result[0]["content"][0]["source"]["media_type"] == "image/jpeg" diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index f4cf19666f6..6164d812f6b 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -191,6 +191,37 @@ def test_summary_call_does_not_force_temperature(self): kwargs = mock_call.call_args.kwargs assert "temperature" not in kwargs + def test_summary_call_passes_live_main_runtime(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "ok" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="gpt-5.4", + provider="openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="codex-token", + api_mode="codex_responses", + quiet_mode=True, + ) + + messages = [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + c._generate_summary(messages) + + assert mock_call.call_args.kwargs["main_runtime"] == { + "model": "gpt-5.4", + "provider": "openai-codex", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-token", + "api_mode": "codex_responses", + } + class TestSummaryFailureCooldown: def test_summary_failure_enters_cooldown_and_skips_retry(self): diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index 9f11d731e36..be4b3b13909 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -87,7 +87,10 @@ def test_known_providers_mapped(self): def test_unmapped_provider_not_in_dict(self): assert "nous" not in PROVIDER_TO_MODELS_DEV - assert "openai-codex" not in PROVIDER_TO_MODELS_DEV + + def test_openai_codex_mapped_to_openai(self): + assert PROVIDER_TO_MODELS_DEV["openai"] == "openai" + assert PROVIDER_TO_MODELS_DEV["openai-codex"] == "openai" class TestExtractContext: diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 1f2f6ada773..5a222cc38bb 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -18,6 +18,7 @@ build_skills_system_prompt, build_nous_subscription_prompt, build_context_files_prompt, + build_environment_hints, CONTEXT_FILE_MAX_CHARS, DEFAULT_AGENT_IDENTITY, TOOL_USE_ENFORCEMENT_GUIDANCE, @@ -26,6 +27,7 @@ MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, + WSL_ENVIRONMENT_HINT, ) from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures @@ -770,6 +772,29 @@ def test_platform_hints_known_platforms(self): assert "cli" in PLATFORM_HINTS +# ========================================================================= +# Environment hints +# ========================================================================= + +class TestEnvironmentHints: + def test_wsl_hint_constant_mentions_mnt(self): + assert "/mnt/c/" in WSL_ENVIRONMENT_HINT + assert "WSL" in WSL_ENVIRONMENT_HINT + + def test_build_environment_hints_on_wsl(self, monkeypatch): + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: True) + result = _pb.build_environment_hints() + assert "/mnt/" in result + assert "WSL" in result + + def test_build_environment_hints_not_wsl(self, monkeypatch): + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + result = _pb.build_environment_hints() + assert result == "" + + # ========================================================================= # Conditional skill activation # ========================================================================= diff --git a/tests/cli/test_tool_progress_scrollback.py b/tests/cli/test_tool_progress_scrollback.py new file mode 100644 index 00000000000..7924f41598b --- /dev/null +++ b/tests/cli/test_tool_progress_scrollback.py @@ -0,0 +1,189 @@ +"""Tests for stacked tool progress scrollback lines in the CLI TUI. + +When tool_progress_mode is "all" or "new", _on_tool_progress should print +persistent lines to scrollback on tool.completed, restoring the stacked +tool history that was lost when the TUI switched to a single-line spinner. +""" + +import os +import sys +import importlib +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Module-level reference to the cli module (set by _make_cli on first call) +_cli_mod = None + + +def _make_cli(tool_progress="all"): + """Create a HermesCLI instance with minimal mocking.""" + global _cli_mod + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": tool_progress}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), \ + patch.dict("os.environ", clean_env, clear=False): + import cli as mod + mod = importlib.reload(mod) + _cli_mod = mod + with patch.object(mod, "get_tool_definitions", return_value=[]), \ + patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}): + return mod.HermesCLI() + + +class TestToolProgressScrollback: + """Stacked scrollback lines for 'all' and 'new' modes.""" + + def test_all_mode_prints_scrollback_on_completed(self): + """In 'all' mode, tool.completed prints a stacked line.""" + cli = _make_cli(tool_progress="all") + # Simulate tool.started + cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"}) + # Simulate tool.completed + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False) + + mock_print.assert_called_once() + line = mock_print.call_args[0][0] + # Should contain tool info (the cute message format has "git log" for terminal) + assert "git log" in line or "$" in line + + def test_all_mode_prints_every_call(self): + """In 'all' mode, consecutive calls to the same tool each get a line.""" + cli = _make_cli(tool_progress="all") + with patch.object(_cli_mod, "_cprint") as mock_print: + # First call + cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) + # Second call (same tool) + cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) + + assert mock_print.call_count == 2 + + def test_new_mode_skips_consecutive_repeats(self): + """In 'new' mode, consecutive calls to the same tool only print once.""" + cli = _make_cli(tool_progress="new") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) + cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) + + assert mock_print.call_count == 1 # Only the first read_file + + def test_new_mode_prints_when_tool_changes(self): + """In 'new' mode, a different tool name triggers a new line.""" + cli = _make_cli(tool_progress="new") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) + cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"}) + cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False) + cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) + + # read_file, search_files, read_file (3rd prints because search_files broke the streak) + assert mock_print.call_count == 3 + + def test_off_mode_no_scrollback(self): + """In 'off' mode, no stacked lines are printed.""" + cli = _make_cli(tool_progress="off") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) + + mock_print.assert_not_called() + + def test_error_suffix_on_failed_tool(self): + """When is_error=True, the stacked line includes [error].""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"}) + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True) + + line = mock_print.call_args[0][0] + assert "[error]" in line + + def test_spinner_still_updates_on_started(self): + """tool.started still updates the spinner text for live display.""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"}) + assert "git status" in cli._spinner_text + + def test_spinner_timer_clears_on_completed(self): + """tool.completed still clears the tool timer.""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"}) + assert cli._tool_start_time > 0 + with patch.object(_cli_mod, "_cprint"): + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) + assert cli._tool_start_time == 0.0 + + def test_concurrent_tools_produce_stacked_lines(self): + """Multiple tool.started followed by multiple tool.completed all produce lines.""" + cli = _make_cli(tool_progress="all") + with patch.object(_cli_mod, "_cprint") as mock_print: + # All start first (concurrent pattern) + cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"}) + cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"}) + # All complete + cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False) + cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False) + + assert mock_print.call_count == 2 + + def test_verbose_mode_no_duplicate_scrollback(self): + """In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output).""" + cli = _make_cli(tool_progress="verbose") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) + + mock_print.assert_not_called() + + def test_pending_info_stores_on_started(self): + """tool.started stores args for later use by tool.completed.""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + assert "terminal" in cli._pending_tool_info + assert len(cli._pending_tool_info["terminal"]) == 1 + assert cli._pending_tool_info["terminal"][0] == {"command": "ls"} + + def test_pending_info_consumed_on_completed(self): + """tool.completed consumes stored args (FIFO for concurrent).""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"}) + assert len(cli._pending_tool_info["terminal"]) == 2 + with patch.object(_cli_mod, "_cprint"): + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False) + # First entry consumed, second remains + assert len(cli._pending_tool_info.get("terminal", [])) == 1 + assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"} diff --git a/tests/gateway/test_clean_shutdown_marker.py b/tests/gateway/test_clean_shutdown_marker.py new file mode 100644 index 00000000000..1a476bc49a5 --- /dev/null +++ b/tests/gateway/test_clean_shutdown_marker.py @@ -0,0 +1,226 @@ +"""Tests for the clean shutdown marker that prevents unwanted session auto-resets. + +When the gateway shuts down gracefully (hermes update, gateway restart, /restart), +it writes a .clean_shutdown marker. On the next startup, if the marker exists, +suspend_recently_active() is skipped so users don't lose their sessions. + +After a crash (no marker), suspension still fires as a safety net for stuck sessions. +""" + +import os +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig, SessionResetPolicy +from gateway.session import SessionEntry, SessionSource, SessionStore + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_source(platform=Platform.TELEGRAM, chat_id="123", user_id="u1"): + return SessionSource(platform=platform, chat_id=chat_id, user_id=user_id) + + +def _make_store(tmp_path, policy=None): + config = GatewayConfig() + if policy: + config.default_reset_policy = policy + return SessionStore(sessions_dir=tmp_path, config=config) + + +# --------------------------------------------------------------------------- +# SessionStore.suspend_recently_active +# --------------------------------------------------------------------------- + +class TestSuspendRecentlyActive: + """Verify suspend_recently_active only marks recent sessions.""" + + def test_suspends_recently_active_sessions(self, tmp_path): + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + assert not entry.suspended + + count = store.suspend_recently_active() + assert count == 1 + + # Re-fetch — should be suspended now + refreshed = store.get_or_create_session(source) + assert refreshed.was_auto_reset + + def test_does_not_suspend_old_sessions(self, tmp_path): + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + + # Backdate the session's updated_at beyond the cutoff + with store._lock: + entry.updated_at = datetime.now() - timedelta(seconds=300) + store._save() + + count = store.suspend_recently_active(max_age_seconds=120) + assert count == 0 + + def test_already_suspended_not_double_counted(self, tmp_path): + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + + # Suspend once + count1 = store.suspend_recently_active() + assert count1 == 1 + + # Create a new session (the old one got reset on next access) + entry2 = store.get_or_create_session(source) + + # Suspend again — the new session is recent but not yet suspended + count2 = store.suspend_recently_active() + assert count2 == 1 + + +# --------------------------------------------------------------------------- +# Clean shutdown marker integration +# --------------------------------------------------------------------------- + +class TestCleanShutdownMarker: + """Test that the marker file controls session suspension on startup.""" + + def test_marker_written_on_graceful_stop(self, tmp_path, monkeypatch): + """stop() should write .clean_shutdown marker.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + marker = tmp_path / ".clean_shutdown" + assert not marker.exists() + + # Create a minimal runner and call the shutdown logic directly + from gateway.run import GatewayRunner + runner = object.__new__(GatewayRunner) + runner._restart_requested = False + runner._restart_detached = False + runner._restart_via_service = False + runner._restart_task_started = False + runner._running = True + runner._draining = False + runner._stop_task = None + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._background_tasks = set() + runner._shutdown_event = MagicMock() + runner._restart_drain_timeout = 5 + runner._exit_code = None + runner._exit_reason = None + runner.adapters = {} + runner.config = GatewayConfig() + + # Mock heavy dependencies + with patch("gateway.run.GatewayRunner._drain_active_agents", new_callable=AsyncMock, return_value=([], False)), \ + patch("gateway.run.GatewayRunner._finalize_shutdown_agents"), \ + patch("gateway.run.GatewayRunner._update_runtime_status"), \ + patch("gateway.status.remove_pid_file"), \ + patch("tools.process_registry.process_registry") as mock_proc_reg, \ + patch("tools.terminal_tool.cleanup_all_environments"), \ + patch("tools.browser_tool.cleanup_all_browsers"): + mock_proc_reg.kill_all = MagicMock() + + import asyncio + asyncio.get_event_loop().run_until_complete(runner.stop()) + + assert marker.exists(), ".clean_shutdown marker should exist after graceful stop" + + def test_marker_skips_suspension_on_startup(self, tmp_path, monkeypatch): + """If .clean_shutdown exists, suspend_recently_active should NOT be called.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + + # Create the marker + marker = tmp_path / ".clean_shutdown" + marker.touch() + + # Create a store with a recently active session + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + assert not entry.suspended + + # Simulate what start() does: + if marker.exists(): + marker.unlink() + # Should NOT call suspend_recently_active + else: + store.suspend_recently_active() + + # Session should NOT be suspended + with store._lock: + store._ensure_loaded_locked() + for e in store._entries.values(): + assert not e.suspended, "Session should NOT be suspended after clean shutdown" + + assert not marker.exists(), "Marker should be cleaned up" + + def test_no_marker_triggers_suspension(self, tmp_path, monkeypatch): + """Without .clean_shutdown marker (crash), suspension should fire.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + + marker = tmp_path / ".clean_shutdown" + assert not marker.exists() + + # Create a store with a recently active session + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + assert not entry.suspended + + # Simulate what start() does: + if marker.exists(): + marker.unlink() + else: + store.suspend_recently_active() + + # Session SHOULD be suspended (crash recovery) + with store._lock: + store._ensure_loaded_locked() + suspended_count = sum(1 for e in store._entries.values() if e.suspended) + assert suspended_count == 1, "Session should be suspended after crash (no marker)" + + def test_marker_written_on_restart_stop(self, tmp_path, monkeypatch): + """stop(restart=True) should also write the marker.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + marker = tmp_path / ".clean_shutdown" + + from gateway.run import GatewayRunner + runner = object.__new__(GatewayRunner) + runner._restart_requested = False + runner._restart_detached = False + runner._restart_via_service = False + runner._restart_task_started = False + runner._running = True + runner._draining = False + runner._stop_task = None + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._background_tasks = set() + runner._shutdown_event = MagicMock() + runner._restart_drain_timeout = 5 + runner._exit_code = None + runner._exit_reason = None + runner.adapters = {} + runner.config = GatewayConfig() + + with patch("gateway.run.GatewayRunner._drain_active_agents", new_callable=AsyncMock, return_value=([], False)), \ + patch("gateway.run.GatewayRunner._finalize_shutdown_agents"), \ + patch("gateway.run.GatewayRunner._update_runtime_status"), \ + patch("gateway.status.remove_pid_file"), \ + patch("tools.process_registry.process_registry") as mock_proc_reg, \ + patch("tools.terminal_tool.cleanup_all_environments"), \ + patch("tools.browser_tool.cleanup_all_browsers"): + mock_proc_reg.kill_all = MagicMock() + + import asyncio + asyncio.get_event_loop().run_until_complete(runner.stop(restart=True)) + + assert marker.exists(), ".clean_shutdown marker should exist after restart-stop too" diff --git a/tests/gateway/test_feishu_onboard.py b/tests/gateway/test_feishu_onboard.py new file mode 100644 index 00000000000..1ba1a64aa3f --- /dev/null +++ b/tests/gateway/test_feishu_onboard.py @@ -0,0 +1,438 @@ +"""Tests for gateway.platforms.feishu — Feishu scan-to-create registration.""" + +import json +from unittest.mock import patch, MagicMock +import pytest + + +def _mock_urlopen(response_data, status=200): + """Create a mock for urllib.request.urlopen that returns JSON response_data.""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(response_data).encode("utf-8") + mock_response.status = status + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + return mock_response + + +class TestPostRegistration: + """Tests for the low-level HTTP helper.""" + + @patch("gateway.platforms.feishu.urlopen") + def test_post_registration_returns_parsed_json(self, mock_urlopen_fn): + from gateway.platforms.feishu import _post_registration + + mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]}) + result = _post_registration("https://accounts.feishu.cn", {"action": "init"}) + assert result["nonce"] == "abc" + assert "client_secret" in result["supported_auth_methods"] + + @patch("gateway.platforms.feishu.urlopen") + def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn): + from gateway.platforms.feishu import _post_registration + + mock_urlopen_fn.return_value = _mock_urlopen({}) + _post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"}) + call_args = mock_urlopen_fn.call_args + request = call_args[0][0] + body = request.data.decode("utf-8") + assert "action=init" in body + assert "key=val" in body + assert request.get_header("Content-type") == "application/x-www-form-urlencoded" + + +class TestInitRegistration: + """Tests for the init step.""" + + @patch("gateway.platforms.feishu.urlopen") + def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn): + from gateway.platforms.feishu import _init_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "nonce": "abc", + "supported_auth_methods": ["client_secret"], + }) + _init_registration("feishu") + + @patch("gateway.platforms.feishu.urlopen") + def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn): + from gateway.platforms.feishu import _init_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "nonce": "abc", + "supported_auth_methods": ["other_method"], + }) + with pytest.raises(RuntimeError, match="client_secret"): + _init_registration("feishu") + + @patch("gateway.platforms.feishu.urlopen") + def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn): + from gateway.platforms.feishu import _init_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "nonce": "abc", + "supported_auth_methods": ["client_secret"], + }) + _init_registration("lark") + call_args = mock_urlopen_fn.call_args + request = call_args[0][0] + assert "larksuite.com" in request.full_url + + +class TestBeginRegistration: + """Tests for the begin step.""" + + @patch("gateway.platforms.feishu.urlopen") + def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn): + from gateway.platforms.feishu import _begin_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "device_code": "dc_123", + "verification_uri_complete": "https://accounts.feishu.cn/qr/abc", + "user_code": "ABCD-1234", + "interval": 5, + "expire_in": 600, + }) + result = _begin_registration("feishu") + assert result["device_code"] == "dc_123" + assert "qr_url" in result + assert "accounts.feishu.cn" in result["qr_url"] + assert result["user_code"] == "ABCD-1234" + assert result["interval"] == 5 + assert result["expire_in"] == 600 + + @patch("gateway.platforms.feishu.urlopen") + def test_begin_sends_correct_archetype(self, mock_urlopen_fn): + from gateway.platforms.feishu import _begin_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "device_code": "dc_123", + "verification_uri_complete": "https://example.com/qr", + "user_code": "X", + "interval": 5, + "expire_in": 600, + }) + _begin_registration("feishu") + request = mock_urlopen_fn.call_args[0][0] + body = request.data.decode("utf-8") + assert "archetype=PersonalAgent" in body + assert "auth_method=client_secret" in body + + +class TestPollRegistration: + """Tests for the poll step.""" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "client_id": "cli_app123", + "client_secret": "secret456", + "user_info": {"open_id": "ou_owner", "tenant_brand": "feishu"}, + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=60, domain="feishu" + ) + assert result is not None + assert result["app_id"] == "cli_app123" + assert result["app_secret"] == "secret456" + assert result["domain"] == "feishu" + assert result["open_id"] == "ou_owner" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1, 2] + mock_time.sleep = MagicMock() + + pending_resp = _mock_urlopen({ + "error": "authorization_pending", + "user_info": {"tenant_brand": "lark"}, + }) + success_resp = _mock_urlopen({ + "client_id": "cli_lark", + "client_secret": "secret_lark", + "user_info": {"open_id": "ou_lark", "tenant_brand": "lark"}, + }) + mock_urlopen_fn.side_effect = [pending_resp, success_resp] + + result = _poll_registration( + device_code="dc_123", interval=0, expire_in=60, domain="feishu" + ) + assert result is not None + assert result["domain"] == "lark" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time): + """Credentials and lark tenant_brand in one response must not be discarded.""" + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "client_id": "cli_lark_direct", + "client_secret": "secret_lark_direct", + "user_info": {"open_id": "ou_lark_direct", "tenant_brand": "lark"}, + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=60, domain="feishu" + ) + assert result is not None + assert result["app_id"] == "cli_lark_direct" + assert result["domain"] == "lark" + assert result["open_id"] == "ou_lark_direct" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "error": "access_denied", + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=60, domain="feishu" + ) + assert result is None + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 999] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "error": "authorization_pending", + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=1, domain="feishu" + ) + assert result is None + + +class TestRenderQr: + """Tests for QR code terminal rendering.""" + + @patch("gateway.platforms.feishu._qrcode_mod", create=True) + def test_render_qr_returns_true_on_success(self, mock_qrcode_mod): + from gateway.platforms.feishu import _render_qr + + mock_qr = MagicMock() + mock_qrcode_mod.QRCode.return_value = mock_qr + assert _render_qr("https://example.com/qr") is True + mock_qr.add_data.assert_called_once_with("https://example.com/qr") + mock_qr.make.assert_called_once_with(fit=True) + mock_qr.print_ascii.assert_called_once() + + def test_render_qr_returns_false_when_qrcode_missing(self): + from gateway.platforms.feishu import _render_qr + + with patch("gateway.platforms.feishu._qrcode_mod", None): + assert _render_qr("https://example.com/qr") is False + + +class TestProbeBot: + """Tests for bot connectivity verification.""" + + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True) + def test_probe_returns_bot_info_on_success(self): + from gateway.platforms.feishu import probe_bot + + with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk: + mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"} + result = probe_bot("cli_app", "secret", "feishu") + + assert result is not None + assert result["bot_name"] == "TestBot" + assert result["bot_open_id"] == "ou_bot123" + + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True) + def test_probe_returns_none_on_failure(self): + from gateway.platforms.feishu import probe_bot + + with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk: + mock_sdk.return_value = None + result = probe_bot("bad_id", "bad_secret", "feishu") + + assert result is None + + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False) + @patch("gateway.platforms.feishu.urlopen") + def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn): + """Without lark_oapi, probe falls back to raw HTTP.""" + from gateway.platforms.feishu import probe_bot + + token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"}) + bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}}) + mock_urlopen_fn.side_effect = [token_resp, bot_resp] + + result = probe_bot("cli_app", "secret", "feishu") + assert result is not None + assert result["bot_name"] == "HttpBot" + + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False) + @patch("gateway.platforms.feishu.urlopen") + def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn): + from gateway.platforms.feishu import probe_bot + from urllib.error import URLError + + mock_urlopen_fn.side_effect = URLError("connection refused") + result = probe_bot("cli_app", "secret", "feishu") + assert result is None + + +class TestQrRegister: + """Tests for the public qr_register entry point.""" + + @patch("gateway.platforms.feishu.probe_bot") + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._poll_registration") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_success_flow( + self, mock_init, mock_begin, mock_poll, mock_render, mock_probe + ): + from gateway.platforms.feishu import qr_register + + mock_begin.return_value = { + "device_code": "dc_123", + "qr_url": "https://example.com/qr", + "user_code": "ABCD", + "interval": 1, + "expire_in": 60, + } + mock_poll.return_value = { + "app_id": "cli_app", + "app_secret": "secret", + "domain": "feishu", + "open_id": "ou_owner", + } + mock_probe.return_value = {"bot_name": "MyBot", "bot_open_id": "ou_bot"} + + result = qr_register() + assert result is not None + assert result["app_id"] == "cli_app" + assert result["app_secret"] == "secret" + assert result["bot_name"] == "MyBot" + mock_init.assert_called_once() + mock_render.assert_called_once() + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_init_failure(self, mock_init): + from gateway.platforms.feishu import qr_register + + mock_init.side_effect = RuntimeError("not supported") + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._poll_registration") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_poll_failure( + self, mock_init, mock_begin, mock_poll, mock_render + ): + from gateway.platforms.feishu import qr_register + + mock_begin.return_value = { + "device_code": "dc_123", + "qr_url": "https://example.com/qr", + "user_code": "ABCD", + "interval": 1, + "expire_in": 60, + } + mock_poll.return_value = None + + result = qr_register() + assert result is None + + # -- Contract: expected errors → None, unexpected errors → propagate -- + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_network_error(self, mock_init): + """URLError (network down) is an expected failure → None.""" + from gateway.platforms.feishu import qr_register + from urllib.error import URLError + + mock_init.side_effect = URLError("DNS resolution failed") + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_json_error(self, mock_init): + """Malformed server response is an expected failure → None.""" + from gateway.platforms.feishu import qr_register + + mock_init.side_effect = json.JSONDecodeError("bad json", "", 0) + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_propagates_unexpected_errors(self, mock_init): + """Bugs (e.g. AttributeError) must not be swallowed — they propagate.""" + from gateway.platforms.feishu import qr_register + + mock_init.side_effect = AttributeError("some internal bug") + with pytest.raises(AttributeError, match="some internal bug"): + qr_register() + + # -- Negative paths: partial/malformed server responses -- + + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_when_begin_missing_device_code( + self, mock_init, mock_begin, mock_render + ): + """Server returns begin response without device_code → RuntimeError → None.""" + from gateway.platforms.feishu import qr_register + + mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code") + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu.probe_bot") + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._poll_registration") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_succeeds_even_when_probe_fails( + self, mock_init, mock_begin, mock_poll, mock_render, mock_probe + ): + """Registration succeeds but probe fails → result with bot_name=None.""" + from gateway.platforms.feishu import qr_register + + mock_begin.return_value = { + "device_code": "dc_123", + "qr_url": "https://example.com/qr", + "user_code": "ABCD", + "interval": 1, + "expire_in": 60, + } + mock_poll.return_value = { + "app_id": "cli_app", + "app_secret": "secret", + "domain": "feishu", + "open_id": "ou_owner", + } + mock_probe.return_value = None # probe failed + + result = qr_register() + assert result is not None + assert result["app_id"] == "cli_app" + assert result["bot_name"] is None + assert result["bot_open_id"] is None diff --git a/tests/gateway/test_internal_event_bypass_pairing.py b/tests/gateway/test_internal_event_bypass_pairing.py index 46a96e5aa24..1c3f9f0c946 100644 --- a/tests/gateway/test_internal_event_bypass_pairing.py +++ b/tests/gateway/test_internal_event_bypass_pairing.py @@ -28,12 +28,16 @@ class _FakeRegistry: def __init__(self, sessions): self._sessions = list(sessions) + self._completion_consumed: set = set() def get(self, session_id): if self._sessions: return self._sessions.pop(0) return None + def is_completion_consumed(self, session_id): + return session_id in self._completion_consumed + def _build_runner(monkeypatch, tmp_path) -> GatewayRunner: """Create a GatewayRunner with notifications set to 'all'.""" diff --git a/tests/gateway/test_setup_feishu.py b/tests/gateway/test_setup_feishu.py new file mode 100644 index 00000000000..26165528e24 --- /dev/null +++ b/tests/gateway/test_setup_feishu.py @@ -0,0 +1,279 @@ +"""Tests for _setup_feishu() in hermes_cli/gateway.py. + +Verifies that the interactive setup writes env vars that correctly drive the +Feishu adapter: credentials, connection mode, DM policy, and group policy. +""" + +import os +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _run_setup_feishu( + *, + qr_result=None, + prompt_yes_no_responses=None, + prompt_choice_responses=None, + prompt_responses=None, + existing_env=None, +): + """Run _setup_feishu() with mocked I/O and return the env vars that were saved. + + Returns a dict of {env_var_name: value} for all save_env_value calls. + """ + existing_env = existing_env or {} + prompt_yes_no_responses = list(prompt_yes_no_responses or [True]) + # QR path: method(0), dm(0), group(0) — 3 choices (no connection mode) + # Manual path: method(1), domain(0), connection(0), dm(0), group(0) — 5 choices + prompt_choice_responses = list(prompt_choice_responses or [0, 0, 0]) + prompt_responses = list(prompt_responses or [""]) + + saved_env = {} + + def mock_save(name, value): + saved_env[name] = value + + def mock_get(name): + return existing_env.get(name, "") + + with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \ + patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \ + patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \ + patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \ + patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \ + patch("hermes_cli.gateway.print_info"), \ + patch("hermes_cli.gateway.print_success"), \ + patch("hermes_cli.gateway.print_warning"), \ + patch("hermes_cli.gateway.print_error"), \ + patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \ + patch("gateway.platforms.feishu.qr_register", return_value=qr_result): + + from hermes_cli.gateway import _setup_feishu + _setup_feishu() + + return saved_env + + +# --------------------------------------------------------------------------- +# QR scan-to-create path +# --------------------------------------------------------------------------- + +class TestSetupFeishuQrPath: + """Tests for the QR scan-to-create happy path.""" + + def test_qr_success_saves_core_credentials(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", + "app_secret": "secret_test", + "domain": "feishu", + "open_id": "ou_owner", + "bot_name": "TestBot", + "bot_open_id": "ou_bot", + }, + prompt_yes_no_responses=[True], # Start QR + prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open + prompt_responses=[""], # home channel: skip + ) + assert env["FEISHU_APP_ID"] == "cli_test" + assert env["FEISHU_APP_SECRET"] == "secret_test" + assert env["FEISHU_DOMAIN"] == "feishu" + + def test_qr_success_does_not_persist_bot_identity(self): + """Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted + in env, so it stays fresh if the user renames the bot later.""" + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", + "app_secret": "secret_test", + "domain": "feishu", + "open_id": "ou_owner", + "bot_name": "TestBot", + "bot_open_id": "ou_bot", + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], + prompt_responses=[""], + ) + assert "FEISHU_BOT_OPEN_ID" not in env + assert "FEISHU_BOT_NAME" not in env + + +# --------------------------------------------------------------------------- +# Connection mode +# --------------------------------------------------------------------------- + +class TestSetupFeishuConnectionMode: + """Connection mode: QR always websocket, manual path lets user choose.""" + + def test_qr_path_defaults_to_websocket(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open + prompt_responses=[""], + ) + assert env["FEISHU_CONNECTION_MODE"] == "websocket" + + @patch("gateway.platforms.feishu.probe_bot", return_value=None) + def test_manual_path_websocket(self, _mock_probe): + env = _run_setup_feishu( + qr_result=None, + prompt_choice_responses=[1, 0, 0, 0, 0], # method=manual, domain=feishu, connection=ws, dm=pairing, group=open + prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel + ) + assert env["FEISHU_CONNECTION_MODE"] == "websocket" + + @patch("gateway.platforms.feishu.probe_bot", return_value=None) + def test_manual_path_webhook(self, _mock_probe): + env = _run_setup_feishu( + qr_result=None, + prompt_choice_responses=[1, 0, 1, 0, 0], # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open + prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel + ) + assert env["FEISHU_CONNECTION_MODE"] == "webhook" + + +# --------------------------------------------------------------------------- +# DM security policy +# --------------------------------------------------------------------------- + +class TestSetupFeishuDmPolicy: + """DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag.""" + + def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None): + return _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": "ou_owner", "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, dm_choice_idx, 0], # method=QR, dm=, group=open + prompt_responses=prompt_responses or [""], + ) + + def test_pairing_sets_feishu_allow_all_false(self): + env = self._run_with_dm_choice(0) + assert env["FEISHU_ALLOW_ALL_USERS"] == "false" + assert env["FEISHU_ALLOWED_USERS"] == "" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + def test_allow_all_sets_feishu_allow_all_true(self): + env = self._run_with_dm_choice(1) + assert env["FEISHU_ALLOW_ALL_USERS"] == "true" + assert env["FEISHU_ALLOWED_USERS"] == "" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + def test_allowlist_sets_feishu_allow_all_false_with_list(self): + env = self._run_with_dm_choice(2, prompt_responses=["ou_user1,ou_user2", ""]) + assert env["FEISHU_ALLOW_ALL_USERS"] == "false" + assert env["FEISHU_ALLOWED_USERS"] == "ou_user1,ou_user2" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + def test_allowlist_prepopulates_with_scan_owner_open_id(self): + """When open_id is available from QR scan, it should be the default allowlist value.""" + # We return the owner's open_id from prompt (+ empty home channel). + env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""]) + assert env["FEISHU_ALLOWED_USERS"] == "ou_owner" + + + +# --------------------------------------------------------------------------- +# Group policy +# --------------------------------------------------------------------------- + +class TestSetupFeishuGroupPolicy: + + def test_open_with_mention(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open + prompt_responses=[""], + ) + assert env["FEISHU_GROUP_POLICY"] == "open" + + def test_disabled(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 1], # method=QR, dm=pairing, group=disabled + prompt_responses=[""], + ) + assert env["FEISHU_GROUP_POLICY"] == "disabled" + + +# --------------------------------------------------------------------------- +# Adapter integration: env vars → FeishuAdapterSettings +# --------------------------------------------------------------------------- + +class TestSetupFeishuAdapterIntegration: + """Verify that env vars written by _setup_feishu() produce a valid adapter config. + + This bridges the gap between 'setup wrote the right env vars' and + 'the adapter will actually initialize correctly from those vars'. + """ + + def _make_env_from_setup(self, dm_idx=0, group_idx=0): + """Run _setup_feishu via QR path and return the env vars it would write.""" + return _run_setup_feishu( + qr_result={ + "app_id": "cli_test_app", + "app_secret": "test_secret_value", + "domain": "feishu", + "open_id": "ou_owner", + "bot_name": "IntegrationBot", + "bot_open_id": "ou_bot_integration", + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, dm_idx, group_idx], # method=QR, dm, group + prompt_responses=[""], + ) + + @patch.dict(os.environ, {}, clear=True) + def test_qr_env_produces_valid_adapter_settings(self): + """QR setup → adapter initializes with websocket mode.""" + env = self._make_env_from_setup() + + with patch.dict(os.environ, env, clear=True): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + adapter = FeishuAdapter(PlatformConfig()) + assert adapter._app_id == "cli_test_app" + assert adapter._app_secret == "test_secret_value" + assert adapter._domain_name == "feishu" + assert adapter._connection_mode == "websocket" + + @patch.dict(os.environ, {}, clear=True) + def test_open_dm_env_sets_correct_adapter_state(self): + """Setup with 'allow all DMs' → adapter sees allow-all flag.""" + env = self._make_env_from_setup(dm_idx=1) + + with patch.dict(os.environ, env, clear=True): + from gateway.platforms.feishu import FeishuAdapter + from gateway.config import PlatformConfig + # Verify adapter initializes without error and env var is correct. + FeishuAdapter(PlatformConfig()) + assert os.getenv("FEISHU_ALLOW_ALL_USERS") == "true" + + @patch.dict(os.environ, {}, clear=True) + def test_group_open_env_sets_adapter_group_policy(self): + """Setup with 'open groups' → adapter group_policy is 'open'.""" + env = self._make_env_from_setup(group_idx=0) + + with patch.dict(os.environ, env, clear=True): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + adapter = FeishuAdapter(PlatformConfig()) + assert adapter._group_policy == "open" diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index 8a2cefbbb6c..c520cbc0d1e 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -403,6 +403,56 @@ async def test_falls_back_when_adapter_unavailable(self, tmp_path): # Should not crash; legacy notification handles this case + @pytest.mark.asyncio + async def test_prompt_forwarded_only_once(self, tmp_path): + """Regression: prompt must not be re-sent on every poll cycle. + + Before the fix, the watcher never deleted .update_prompt.json after + forwarding, causing the same prompt to be sent every poll_interval. + """ + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + (hermes_home / ".update_output.txt").write_text("") + + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + # Write the prompt file up front (before the watcher starts). + # The watcher should forward it exactly once, then delete it. + prompt = {"prompt": "Would you like to configure new options now? Y/n", + "default": "n", "id": "dup-test"} + (hermes_home / ".update_prompt.json").write_text(json.dumps(prompt)) + + async def finish_after_polls(): + # Wait long enough for multiple poll cycles to occur, then + # simulate a response + completion. + await asyncio.sleep(1.0) + (hermes_home / ".update_response").write_text("n") + await asyncio.sleep(0.3) + (hermes_home / ".update_exit_code").write_text("0") + + with patch("gateway.run._hermes_home", hermes_home): + task = asyncio.create_task(finish_after_polls()) + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=10.0, + ) + await task + + # Count how many times the prompt text was sent + all_sent = [str(c) for c in mock_adapter.send.call_args_list] + prompt_sends = [s for s in all_sent if "configure new options" in s] + assert len(prompt_sends) == 1, ( + f"Prompt was sent {len(prompt_sends)} times (expected 1). " + f"All sends: {all_sent}" + ) + # --------------------------------------------------------------------------- # Message interception for update prompts diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index bb439fa9a6b..f2afe1049ac 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -64,13 +64,44 @@ def test_format_message_returns_empty_string_for_none(self): class TestWeixinChunking: - def test_split_text_keeps_short_multiline_message_in_single_chunk(self): + def test_split_text_splits_short_chatty_replies_into_separate_bubbles(self): adapter = _make_adapter() content = adapter.format_message("第一行\n第二行\n第三行") chunks = adapter._split_text(content) - assert chunks == ["第一行\n第二行\n第三行"] + assert chunks == ["第一行", "第二行", "第三行"] + + def test_split_text_keeps_structured_table_block_together(self): + adapter = _make_adapter() + + content = adapter.format_message( + "- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3" + ) + chunks = adapter._split_text(content) + + assert chunks == ["- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"] + + def test_split_text_keeps_four_line_structured_blocks_together(self): + adapter = _make_adapter() + + content = adapter.format_message( + "今天结论:\n" + "- 留存下降 3%\n" + "- 转化上涨 8%\n" + "- 主要问题在首日激活" + ) + chunks = adapter._split_text(content) + + assert chunks == ["今天结论:\n- 留存下降 3%\n- 转化上涨 8%\n- 主要问题在首日激活"] + + def test_split_text_keeps_heading_with_body_together(self): + adapter = _make_adapter() + + content = adapter.format_message("## 结论\n这是正文") + chunks = adapter._split_text(content) + + assert chunks == ["**结论**\n这是正文"] def test_split_text_keeps_short_reformatted_table_in_single_chunk(self): adapter = _make_adapter() diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 4119126e668..f05a80b6ac1 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -14,6 +14,7 @@ PROVIDER_REGISTRY, _read_codex_tokens, _save_codex_tokens, + _write_codex_cli_tokens, _import_codex_cli_tokens, get_codex_auth_status, get_provider_auth_state, @@ -161,7 +162,7 @@ def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch): def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): - """Verify Hermes never writes to ~/.codex/auth.json.""" + """Verify _save_codex_tokens writes only to Hermes auth store, not ~/.codex/.""" hermes_home = tmp_path / "hermes" codex_home = tmp_path / "codex-cli" hermes_home.mkdir(parents=True, exist_ok=True) @@ -173,7 +174,7 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): _save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"}) - # ~/.codex/auth.json should NOT exist + # ~/.codex/auth.json should NOT exist — _save_codex_tokens only touches Hermes store assert not (codex_home / "auth.json").exists() # Hermes auth store should have the tokens @@ -181,6 +182,98 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): assert data["tokens"]["access_token"] == "hermes-at" +def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch): + """_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.""" + codex_home = tmp_path / "codex-cli" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + _write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z") + + auth_path = codex_home / "auth.json" + assert auth_path.exists() + data = json.loads(auth_path.read_text()) + assert data["tokens"]["access_token"] == "new-access" + assert data["tokens"]["refresh_token"] == "new-refresh" + assert data["last_refresh"] == "2026-04-12T00:00:00Z" + # Verify file permissions are restricted + assert (auth_path.stat().st_mode & 0o777) == 0o600 + + +def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch): + """_write_codex_cli_tokens preserves extra fields in existing auth.json.""" + codex_home = tmp_path / "codex-cli" + codex_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + existing = { + "tokens": { + "access_token": "old-access", + "refresh_token": "old-refresh", + "extra_field": "preserved", + }, + "last_refresh": "2026-01-01T00:00:00Z", + "custom_key": "keep_me", + } + (codex_home / "auth.json").write_text(json.dumps(existing)) + + _write_codex_cli_tokens("updated-access", "updated-refresh") + + data = json.loads((codex_home / "auth.json").read_text()) + assert data["tokens"]["access_token"] == "updated-access" + assert data["tokens"]["refresh_token"] == "updated-refresh" + assert data["tokens"]["extra_field"] == "preserved" + assert data["custom_key"] == "keep_me" + # last_refresh not updated since we didn't pass it + assert data["last_refresh"] == "2026-01-01T00:00:00Z" + + +def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch): + """_write_codex_cli_tokens creates parent directories if missing.""" + codex_home = tmp_path / "does" / "not" / "exist" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + _write_codex_cli_tokens("at", "rt") + + assert (codex_home / "auth.json").exists() + data = json.loads((codex_home / "auth.json").read_text()) + assert data["tokens"]["access_token"] == "at" + + +def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch): + """After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.""" + from hermes_cli.auth import _refresh_codex_auth_tokens + + hermes_home = tmp_path / "hermes" + codex_home = tmp_path / "codex-cli" + hermes_home.mkdir(parents=True, exist_ok=True) + codex_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + # Write initial CLI tokens + (codex_home / "auth.json").write_text(json.dumps({ + "tokens": {"access_token": "old-at", "refresh_token": "old-rt"}, + })) + + # Mock the pure refresh to return new tokens + monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: { + "access_token": "refreshed-at", + "refresh_token": "refreshed-rt", + "last_refresh": "2026-04-12T01:00:00Z", + }) + + _refresh_codex_auth_tokens( + {"access_token": "old-at", "refresh_token": "old-rt"}, + timeout_seconds=10, + ) + + # Verify CLI file was updated + cli_data = json.loads((codex_home / "auth.json").read_text()) + assert cli_data["tokens"]["access_token"] == "refreshed-at" + assert cli_data["tokens"]["refresh_token"] == "refreshed-rt" + + def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_hermes_auth(hermes_home) diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index da3002f8c4d..d7528890e20 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -58,13 +58,13 @@ def test_finds_openclaw_dir(self, tmp_path): def test_finds_legacy_dirs(self, tmp_path): clawdbot = tmp_path / ".clawdbot" clawdbot.mkdir() - moldbot = tmp_path / ".moldbot" - moldbot.mkdir() + moltbot = tmp_path / ".moltbot" + moltbot.mkdir() with patch("pathlib.Path.home", return_value=tmp_path): found = claw_mod._find_openclaw_dirs() assert len(found) == 2 assert clawdbot in found - assert moldbot in found + assert moltbot in found def test_returns_empty_when_none_exist(self, tmp_path): with patch("pathlib.Path.home", return_value=tmp_path): @@ -297,7 +297,6 @@ def test_execute_with_confirmation(self, tmp_path, capsys): patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), patch.object(claw_mod, "get_config_path", return_value=config_path), patch.object(claw_mod, "prompt_yes_no", return_value=True), - patch.object(claw_mod, "_offer_source_archival"), patch("sys.stdin", mock_stdin), ): claw_mod._cmd_migrate(args) @@ -306,43 +305,8 @@ def test_execute_with_confirmation(self, tmp_path, capsys): assert "Migration Results" in captured.out assert "Migration complete!" in captured.out - def test_execute_offers_archival_on_success(self, tmp_path, capsys): - """After successful migration, _offer_source_archival should be called.""" - openclaw_dir = tmp_path / ".openclaw" - openclaw_dir.mkdir() - - fake_mod = ModuleType("openclaw_to_hermes") - fake_mod.resolve_selected_options = MagicMock(return_value={"soul"}) - fake_migrator = MagicMock() - fake_migrator.migrate.return_value = { - "summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0}, - "items": [ - {"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")}, - ], - } - fake_mod.Migrator = MagicMock(return_value=fake_migrator) - - args = Namespace( - source=str(openclaw_dir), - dry_run=False, preset="full", overwrite=False, - migrate_secrets=False, workspace_target=None, - skill_conflict="skip", yes=True, - ) - - with ( - patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), - patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), - patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"), - patch.object(claw_mod, "save_config"), - patch.object(claw_mod, "load_config", return_value={}), - patch.object(claw_mod, "_offer_source_archival") as mock_archival, - ): - claw_mod._cmd_migrate(args) - - mock_archival.assert_called_once_with(openclaw_dir, True) - - def test_dry_run_skips_archival(self, tmp_path, capsys): - """Dry run should not offer archival.""" + def test_dry_run_does_not_touch_source(self, tmp_path, capsys): + """Dry run should not modify the source directory.""" openclaw_dir = tmp_path / ".openclaw" openclaw_dir.mkdir() @@ -369,11 +333,10 @@ def test_dry_run_skips_archival(self, tmp_path, capsys): patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"), patch.object(claw_mod, "save_config"), patch.object(claw_mod, "load_config", return_value={}), - patch.object(claw_mod, "_offer_source_archival") as mock_archival, ): claw_mod._cmd_migrate(args) - mock_archival.assert_not_called() + assert openclaw_dir.is_dir() # Source untouched def test_execute_cancelled_by_user(self, tmp_path, capsys): openclaw_dir = tmp_path / ".openclaw" @@ -506,73 +469,6 @@ def test_full_preset_enables_secrets(self, tmp_path, capsys): assert call_kwargs["migrate_secrets"] is True -# --------------------------------------------------------------------------- -# _offer_source_archival -# --------------------------------------------------------------------------- - - -class TestOfferSourceArchival: - """Test the post-migration archival offer.""" - - def test_archives_with_auto_yes(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - (source / "workspace").mkdir() - (source / "workspace" / "todo.json").write_text("{}") - - claw_mod._offer_source_archival(source, auto_yes=True) - - captured = capsys.readouterr() - assert "Archived" in captured.out - assert not source.exists() - assert (tmp_path / ".openclaw.pre-migration").is_dir() - - def test_skips_when_user_declines(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - - mock_stdin = MagicMock() - mock_stdin.isatty.return_value = True - - with ( - patch.object(claw_mod, "prompt_yes_no", return_value=False), - patch("sys.stdin", mock_stdin), - ): - claw_mod._offer_source_archival(source, auto_yes=False) - - captured = capsys.readouterr() - assert "Skipped" in captured.out - assert source.is_dir() # Still exists - - def test_noop_when_source_missing(self, tmp_path, capsys): - claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True) - captured = capsys.readouterr() - assert captured.out == "" # No output - - def test_shows_state_files(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - ws = source / "workspace" - ws.mkdir() - (ws / "todo.json").write_text("{}") - - with patch.object(claw_mod, "prompt_yes_no", return_value=False): - claw_mod._offer_source_archival(source, auto_yes=False) - - captured = capsys.readouterr() - assert "todo.json" in captured.out - - def test_handles_archive_error(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - - with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")): - claw_mod._offer_source_archival(source, auto_yes=True) - - captured = capsys.readouterr() - assert "Could not archive" in captured.out - - # --------------------------------------------------------------------------- # _cmd_cleanup # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_codex_cli_model_picker.py b/tests/hermes_cli/test_codex_cli_model_picker.py new file mode 100644 index 00000000000..2af837fde7f --- /dev/null +++ b/tests/hermes_cli/test_codex_cli_model_picker.py @@ -0,0 +1,241 @@ +"""Regression test: openai-codex must appear in /model picker when +credentials are only in the Codex CLI shared file (~/.codex/auth.json) +and haven't been migrated to the Hermes auth store yet. + +Root cause: list_authenticated_providers() checked the raw Hermes auth +store but didn't know about the Codex CLI fallback import path. + +Fix: _seed_from_singletons() now imports from the Codex CLI when the +Hermes auth store has no openai-codex tokens, and +list_authenticated_providers() falls back to load_pool() for OAuth +providers. +""" + +import base64 +import json +import os +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + + +def _make_fake_jwt(expiry_offset: int = 3600) -> str: + """Build a fake JWT with a future expiry.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() + exp = int(time.time()) + expiry_offset + payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode() + payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode() + return f"{header}.{payload}.fakesig" + + +@pytest.fixture() +def codex_cli_only_env(tmp_path, monkeypatch): + """Set up an environment where Codex tokens exist only in ~/.codex/auth.json, + NOT in the Hermes auth store.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + codex_home = tmp_path / ".codex" + codex_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + # Empty Hermes auth store + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + # Valid Codex CLI tokens + fake_jwt = _make_fake_jwt() + (codex_home / "auth.json").write_text( + json.dumps({ + "tokens": { + "access_token": fake_jwt, + "refresh_token": "fake-refresh-token", + } + }) + ) + + # Clear provider env vars so only OAuth is a detection path + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", "GEMINI_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env): + """openai-codex should appear when tokens only exist in ~/.codex/auth.json.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openai-codex", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" in slugs, ( + f"openai-codex not found in /model picker providers: {slugs}" + ) + + codex = next(p for p in providers if p["slug"] == "openai-codex") + assert codex["is_current"] is True + assert codex["total_models"] > 0 + + +def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env): + """After the /model picker detects Codex CLI tokens, they should be + migrated into the Hermes auth store for subsequent fast lookups.""" + from hermes_cli.model_switch import list_authenticated_providers + + # First call triggers migration + list_authenticated_providers(current_provider="openai-codex") + + # Verify tokens are now in Hermes auth store + auth_path = codex_cli_only_env / "auth.json" + store = json.loads(auth_path.read_text()) + providers = store.get("providers", {}) + assert "openai-codex" in providers, ( + f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}" + ) + tokens = providers["openai-codex"].get("tokens", {}) + assert tokens.get("access_token"), "access_token missing after migration" + assert tokens.get("refresh_token"), "refresh_token missing after migration" + + +@pytest.fixture() +def hermes_auth_only_env(tmp_path, monkeypatch): + """Tokens already in Hermes auth store (no Codex CLI needed).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Point CODEX_HOME to nonexistent dir to prove it's not needed + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 2, + "providers": { + "openai-codex": { + "tokens": { + "access_token": _make_fake_jwt(), + "refresh_token": "fake-refresh", + }, + "last_refresh": "2026-04-12T00:00:00Z", + } + }, + })) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_normal_path_still_works(hermes_auth_only_env): + """openai-codex appears when tokens are already in Hermes auth store.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openai-codex", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" in slugs + + +@pytest.fixture() +def claude_code_only_env(tmp_path, monkeypatch): + """Set up an environment where Anthropic credentials only exist in + ~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes + auth store.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # No Codex CLI + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + # Claude Code credentials in the correct format + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / ".credentials.json").write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": _make_fake_jwt(), + "refreshToken": "fake-refresh", + "expiresAt": int(time.time() * 1000) + 3_600_000, + } + })) + + # Patch Path.home() so the adapter finds the file + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_claude_code_file_detected_by_model_picker(claude_code_only_env): + """anthropic should appear when credentials only exist in ~/.claude/.credentials.json.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="anthropic", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "anthropic" in slugs, ( + f"anthropic not found in /model picker providers: {slugs}" + ) + + anthropic = next(p for p in providers if p["slug"] == "anthropic") + assert anthropic["is_current"] is True + assert anthropic["total_models"] > 0 + + +def test_no_codex_when_no_credentials(tmp_path, monkeypatch): + """openai-codex should NOT appear when no credentials exist anywhere.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", "GEMINI_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openrouter", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" not in slugs, ( + "openai-codex should not appear without any credentials" + ) diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index d48610a6304..a0123670be9 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -122,3 +122,54 @@ def test_no_saved_model_still_works(self, config_home): model = config.get("model") assert isinstance(model, dict) assert model["default"] == "model-X" + + def test_api_mode_set_from_provider_info(self, config_home): + """When custom_providers entry has api_mode, it should be applied.""" + import yaml + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "Anthropic Proxy", + "base_url": "https://proxy.example.com/anthropic", + "api_key": "***", + "model": "claude-3", + "api_mode": "anthropic_messages", + } + + with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]), \ + patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("builtins.input", return_value="1"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert model.get("api_mode") == "anthropic_messages" + + def test_api_mode_cleared_when_not_specified(self, config_home): + """When custom_providers entry has no api_mode, stale api_mode is removed.""" + import yaml + from hermes_cli.main import _model_flow_named_custom + + # Pre-seed a stale api_mode in config + config_path = config_home / "config.yaml" + config_path.write_text(yaml.dump({"model": {"api_mode": "anthropic_messages"}})) + + provider_info = { + "name": "My vLLM", + "base_url": "https://vllm.example.com/v1", + "api_key": "***", + "model": "llama-3", + } + + with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \ + patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("builtins.input", return_value="1"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert "api_mode" not in model, "Stale api_mode should be removed" diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 55f7ac69c77..a06facd300a 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -257,3 +257,76 @@ def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home, assert model.get("provider") == "opencode-go" assert model.get("default") == "minimax-m2.5" assert model.get("api_mode") == "anthropic_messages" + + +class TestBaseUrlValidation: + """Reject non-URL values in the base URL prompt (e.g. shell commands).""" + + def test_invalid_base_url_rejected(self, config_home, monkeypatch, capsys): + """Typing a non-URL string should not be saved as the base URL.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + # User types a shell command instead of a URL at the base URL prompt + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="nano ~/.hermes/.env"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + # The garbage value should NOT have been saved + saved = get_env_value("GLM_BASE_URL") or "" + assert not saved or saved.startswith(("http://", "https://")), \ + f"Non-URL value was saved as GLM_BASE_URL: {saved}" + captured = capsys.readouterr() + assert "Invalid URL" in captured.out + + def test_valid_base_url_accepted(self, config_home, monkeypatch): + """A proper URL should be saved normally.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="https://custom.z.ai/api/paas/v4"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + saved = get_env_value("GLM_BASE_URL") or "" + assert saved == "https://custom.z.ai/api/paas/v4" + + def test_empty_base_url_keeps_default(self, config_home, monkeypatch): + """Pressing Enter (empty) should not change the base URL.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + monkeypatch.delenv("GLM_BASE_URL", raising=False) + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value=""): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + saved = get_env_value("GLM_BASE_URL") or "" + assert saved == "", "Empty input should not save a base URL" diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index c970cb6c538..e6de2f67fc6 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -177,7 +177,8 @@ def test_clone_config_missing_files_skipped(self, profile_env): # No error; optional files just not copied assert not (profile_dir / "config.yaml").exists() assert not (profile_dir / ".env").exists() - assert not (profile_dir / "SOUL.md").exists() + # SOUL.md is always seeded with the default even when clone source lacks it + assert (profile_dir / "SOUL.md").exists() # =================================================================== diff --git a/tests/hermes_cli/test_tips.py b/tests/hermes_cli/test_tips.py new file mode 100644 index 00000000000..88e00e0ce61 --- /dev/null +++ b/tests/hermes_cli/test_tips.py @@ -0,0 +1,77 @@ +"""Tests for hermes_cli/tips.py — random tip display at session start.""" + +import pytest +from hermes_cli.tips import TIPS, get_random_tip, get_tip_count + + +class TestTipsCorpus: + """Validate the tip corpus itself.""" + + def test_has_at_least_200_tips(self): + assert len(TIPS) >= 200, f"Expected 200+ tips, got {len(TIPS)}" + + def test_no_duplicates(self): + assert len(TIPS) == len(set(TIPS)), "Duplicate tips found" + + def test_all_tips_are_strings(self): + for i, tip in enumerate(TIPS): + assert isinstance(tip, str), f"Tip {i} is not a string: {type(tip)}" + + def test_no_empty_tips(self): + for i, tip in enumerate(TIPS): + assert tip.strip(), f"Tip {i} is empty or whitespace-only" + + def test_max_length_reasonable(self): + """Tips should fit on a single terminal line (~120 chars max).""" + for i, tip in enumerate(TIPS): + assert len(tip) <= 150, ( + f"Tip {i} too long ({len(tip)} chars): {tip[:60]}..." + ) + + def test_no_leading_trailing_whitespace(self): + for i, tip in enumerate(TIPS): + assert tip == tip.strip(), f"Tip {i} has leading/trailing whitespace" + + +class TestGetRandomTip: + """Validate the get_random_tip() function.""" + + def test_returns_string(self): + tip = get_random_tip() + assert isinstance(tip, str) + assert len(tip) > 0 + + def test_returns_tip_from_corpus(self): + tip = get_random_tip() + assert tip in TIPS + + def test_randomness(self): + """Multiple calls should eventually return different tips.""" + seen = set() + for _ in range(50): + seen.add(get_random_tip()) + # With 200+ tips and 50 draws, we should see at least 10 unique + assert len(seen) >= 10, f"Only got {len(seen)} unique tips in 50 draws" + + +class TestGetTipCount: + def test_matches_corpus_length(self): + assert get_tip_count() == len(TIPS) + + +class TestTipIntegrationInCLI: + """Test that the tip display code in cli.py works correctly.""" + + def test_tip_import_works(self): + """The import used in cli.py must succeed.""" + from hermes_cli.tips import get_random_tip + assert callable(get_random_tip) + + def test_tip_display_format(self): + """Verify the Rich markup format doesn't break.""" + tip = get_random_tip() + color = "#B8860B" + markup = f"[dim {color}]✦ Tip: {tip}[/]" + # Should not contain nested/broken Rich tags + assert markup.count("[/]") == 1 + assert "[dim #B8860B]" in markup diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 822b22742d5..f3f2a0444ae 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -798,3 +798,120 @@ def fake_run(cmd, **kwargs): pids = gateway_cli.find_gateway_pids() assert pids == [100] + + +# --------------------------------------------------------------------------- +# Gateway mode writes exit code before restart (#8300) +# --------------------------------------------------------------------------- + + +class TestGatewayModeWritesExitCodeEarly: + """When running as ``hermes update --gateway``, the exit code marker must be + written *before* the gateway restart attempt. Without this, systemd's + ``KillMode=mixed`` kills the update process (and its wrapping shell) during + the cgroup teardown, so the shell epilogue that normally writes the exit + code never executes. The new gateway's update watcher then polls for 30 + minutes and sends a spurious timeout message. + """ + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_exit_code_written_in_gateway_mode( + self, mock_run, _mock_which, capsys, tmp_path, monkeypatch, + ): + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + # Point HERMES_HOME at a temp dir so the marker file lands there + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_cli.config as _cfg + monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home) + # Also patch the module-level ref used by cmd_update + import hermes_cli.main as _main_mod + monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home) + + mock_run.side_effect = _make_run_side_effect(commit_count="1") + + args = SimpleNamespace(gateway=True) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(args) + + exit_code_path = hermes_home / ".update_exit_code" + assert exit_code_path.exists(), ".update_exit_code not written in gateway mode" + assert exit_code_path.read_text() == "0" + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_exit_code_not_written_in_normal_mode( + self, mock_run, _mock_which, capsys, tmp_path, monkeypatch, + ): + """Non-gateway mode should NOT write the exit code (the shell does it).""" + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_cli.config as _cfg + monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home) + import hermes_cli.main as _main_mod + monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home) + + mock_run.side_effect = _make_run_side_effect(commit_count="1") + + args = SimpleNamespace(gateway=False) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(args) + + exit_code_path = hermes_home / ".update_exit_code" + assert not exit_code_path.exists(), ".update_exit_code should not be written outside gateway mode" + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_exit_code_written_before_restart_call( + self, mock_run, _mock_which, capsys, tmp_path, monkeypatch, + ): + """Exit code must exist BEFORE systemctl restart is called.""" + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_cli.config as _cfg + monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home) + import hermes_cli.main as _main_mod + monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home) + + exit_code_path = hermes_home / ".update_exit_code" + + # Track whether exit code exists when systemctl restart is called + exit_code_existed_at_restart = [] + + original_side_effect = _make_run_side_effect( + commit_count="1", systemd_active=True, + ) + + def tracking_side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + if "systemctl" in joined and "restart" in joined: + exit_code_existed_at_restart.append(exit_code_path.exists()) + return original_side_effect(cmd, **kwargs) + + mock_run.side_effect = tracking_side_effect + + args = SimpleNamespace(gateway=True) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(args) + + assert exit_code_existed_at_restart, "systemctl restart was never called" + assert exit_code_existed_at_restart[0] is True, \ + ".update_exit_code must exist BEFORE systemctl restart (cgroup kill race)" diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index 1b4423414ee..0738b1d438d 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -26,6 +26,7 @@ def _make_agent( agent.provider = "openrouter" agent.base_url = "https://openrouter.ai/api/v1" agent.api_key = "sk-test" + agent.api_mode = "chat_completions" agent.quiet_mode = True agent.log_prefix = "" agent.compression_enabled = compression_enabled @@ -99,6 +100,36 @@ def test_no_warning_when_aux_context_sufficient(mock_get_client, mock_ctx_len): assert agent._compression_warning is None +def test_feasibility_check_passes_live_main_runtime(): + """Compression feasibility should probe using the live session runtime.""" + agent = _make_agent(main_context=200_000, threshold_percent=0.50) + agent.model = "gpt-5.4" + agent.provider = "openai-codex" + agent.base_url = "https://chatgpt.com/backend-api/codex" + agent.api_key = "codex-token" + agent.api_mode = "codex_responses" + + mock_client = MagicMock() + mock_client.base_url = "https://chatgpt.com/backend-api/codex" + mock_client.api_key = "codex-token" + + with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(mock_client, "gpt-5.4")) as mock_get_client, \ + patch("agent.model_metadata.get_model_context_length", return_value=200_000): + agent._emit_status = lambda msg: None + agent._check_compression_model_feasibility() + + mock_get_client.assert_called_once_with( + "compression", + main_runtime={ + "model": "gpt-5.4", + "provider": "openai-codex", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-token", + "api_mode": "codex_responses", + }, + ) + + @patch("agent.auxiliary_client.get_text_auxiliary_client") def test_warns_when_no_auxiliary_provider(mock_get_client): """Warning emitted when no auxiliary provider is configured.""" diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index d716b59b273..e4ae10f20c3 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -302,6 +302,17 @@ def test_mixed_orphaned_and_paired_tags(self, agent): assert "" not in result assert "visible" in result + def test_thought_block_removed(self, agent): + """Gemma 4 uses tags for inline reasoning.""" + result = agent._strip_think_blocks("internal reasoning answer") + assert "internal reasoning" not in result + assert "" not in result + assert "answer" in result + + def test_orphaned_thought_tag(self, agent): + result = agent._strip_think_blocks("orphaned reasoning without close") + assert "" not in result + class TestExtractReasoning: def test_reasoning_field(self, agent): diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index 99d126bed57..671d764f0d9 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -185,6 +185,38 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text +def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path): + """MESSAGING_CWD pointing inside the OpenClaw source dir should be skipped.""" + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + + # Workspace path is inside the source directory + ws_path = str(source / "workspace") + (source / "credentials").mkdir(parents=True) + (source / "openclaw.json").write_text( + json.dumps({"agents": {"defaults": {"workspace": ws_path}}}), + encoding="utf-8", + ) + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=True, + output_dir=target / "migration-report", + selected_options={"messaging-settings"}, + ) + migrator.migrate() + + env_path = target / ".env" + if env_path.exists(): + assert "MESSAGING_CWD" not in env_path.read_text(encoding="utf-8") + + def test_migrator_can_execute_only_selected_categories(tmp_path: Path): mod = load_module() source = tmp_path / ".openclaw" @@ -722,3 +754,98 @@ def test_skill_installs_cleanly_under_skills_guard(): KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"} for f in result.findings: assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}" + + +# ── rebrand_text tests ──────────────────────────────────────── + + +def test_rebrand_text_replaces_openclaw_variants(): + mod = load_module() + assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11" + assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode" + assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great" + assert mod.rebrand_text("openclaw should always respond concisely") == "Hermes should always respond concisely" + assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well" + + +def test_rebrand_text_replaces_legacy_bot_names(): + mod = load_module() + assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone" + assert mod.rebrand_text("clawdbot prefers tabs") == "Hermes prefers tabs" + assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish" + assert mod.rebrand_text("moltbot uses Python") == "Hermes uses Python" + + +def test_rebrand_text_preserves_unrelated_content(): + mod = load_module() + text = "User prefers dark mode and lives in Las Vegas" + assert mod.rebrand_text(text) == text + + +def test_rebrand_text_handles_multiple_replacements(): + mod = load_module() + text = "OpenClaw said to ask ClawdBot about MoltBot settings" + assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings" + + +def test_migrate_memory_rebrands_entries(tmp_path): + mod = load_module() + source_root = tmp_path / "openclaw" + source_root.mkdir() + workspace = source_root / "workspace" + workspace.mkdir() + memory_md = workspace / "MEMORY.md" + memory_md.write_text( + "# Memory\n\n- OpenClaw should use Python 3.11\n- ClawdBot prefers dark mode\n", + encoding="utf-8", + ) + + target_root = tmp_path / "hermes" + target_root.mkdir() + (target_root / "memories").mkdir() + + migrator = mod.Migrator( + source_root=source_root, + target_root=target_root, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=tmp_path / "report", + selected_options={"memory"}, + ) + migrator.migrate() + + result = (target_root / "memories" / "MEMORY.md").read_text(encoding="utf-8") + assert "OpenClaw" not in result + assert "ClawdBot" not in result + assert "Hermes" in result + + +def test_migrate_soul_rebrands_content(tmp_path): + mod = load_module() + source_root = tmp_path / "openclaw" + source_root.mkdir() + workspace = source_root / "workspace" + workspace.mkdir() + soul_md = workspace / "SOUL.md" + soul_md.write_text("You are OpenClaw, an AI assistant made by SparkLab.", encoding="utf-8") + + target_root = tmp_path / "hermes" + target_root.mkdir() + + migrator = mod.Migrator( + source_root=source_root, + target_root=target_root, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=tmp_path / "report", + selected_options={"soul"}, + ) + migrator.migrate() + + result = (target_root / "SOUL.md").read_text(encoding="utf-8") + assert "OpenClaw" not in result + assert "You are Hermes" in result diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py new file mode 100644 index 00000000000..b5f4286727f --- /dev/null +++ b/tests/test_empty_model_fallback.py @@ -0,0 +1,120 @@ +"""Tests for empty model fallback — when provider is configured but model is missing.""" + +from unittest.mock import MagicMock, patch +import pytest + + +class TestGetDefaultModelForProvider: + """Unit tests for hermes_cli.models.get_default_model_for_provider.""" + + def test_known_provider_returns_first_model(self): + from hermes_cli.models import get_default_model_for_provider + result = get_default_model_for_provider("openai-codex") + # Should return first model from _PROVIDER_MODELS["openai-codex"] + assert result + assert isinstance(result, str) + + def test_openrouter_returns_empty(self): + """OpenRouter uses dynamic model fetch, no static catalog entry.""" + from hermes_cli.models import get_default_model_for_provider + # OpenRouter is not in _PROVIDER_MODELS — it uses live fetching + result = get_default_model_for_provider("openrouter") + assert result == "" + + def test_unknown_provider_returns_empty(self): + from hermes_cli.models import get_default_model_for_provider + assert get_default_model_for_provider("nonexistent-provider") == "" + + def test_custom_provider_returns_empty(self): + """Custom provider has no model catalog — should return empty.""" + from hermes_cli.models import get_default_model_for_provider + # Custom providers don't have entries in _PROVIDER_MODELS + assert get_default_model_for_provider("some-random-custom") == "" + + +class TestGatewayEmptyModelFallback: + """Test that _resolve_session_agent_runtime fills in empty model from provider catalog.""" + + def test_empty_model_filled_from_provider(self): + """When config has no model but provider is openai-codex, use first codex model.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + + # Mock _resolve_gateway_model to return empty string + # Mock _resolve_runtime_agent_kwargs to return openai-codex provider + with patch("gateway.run._resolve_gateway_model", return_value=""), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "openai-codex", + "api_key": "test-key", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_mode": "codex_responses", + }): + model, kwargs = runner._resolve_session_agent_runtime() + + # Model should have been filled in from provider catalog + assert model, "Model should not be empty when provider is known" + assert isinstance(model, str) + assert kwargs["provider"] == "openai-codex" + + def test_nonempty_model_not_overridden(self): + """When config has a model set, don't override it.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + + with patch("gateway.run._resolve_gateway_model", return_value="gpt-5.4"), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "openai-codex", + "api_key": "test-key", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_mode": "codex_responses", + }): + model, kwargs = runner._resolve_session_agent_runtime() + + assert model == "gpt-5.4", "Explicit model should not be overridden" + + def test_empty_model_no_provider_stays_empty(self): + """When both model and provider are empty, model stays empty.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + + with patch("gateway.run._resolve_gateway_model", return_value=""), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "", + "api_key": "test-key", + "base_url": "https://example.com", + "api_mode": "chat_completions", + }): + model, kwargs = runner._resolve_session_agent_runtime() + + # Can't fill in a default without knowing the provider + assert model == "" + + +class TestResolveGatewayModel: + """Test _resolve_gateway_model reads model from config correctly.""" + + def test_returns_default_key(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": {"default": "gpt-5.4"}}) == "gpt-5.4" + + def test_returns_model_key_fallback(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": {"model": "gpt-5.4"}}) == "gpt-5.4" + + def test_returns_empty_when_missing(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": {}}) == "" + + def test_returns_empty_when_no_model_section(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({}) == "" + + def test_string_model_config(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": "my-model"}) == "my-model" diff --git a/tests/test_ipv4_preference.py b/tests/test_ipv4_preference.py new file mode 100644 index 00000000000..c57016e2235 --- /dev/null +++ b/tests/test_ipv4_preference.py @@ -0,0 +1,114 @@ +"""Tests for network.force_ipv4 — the socket.getaddrinfo monkey-patch.""" + +import importlib +import socket +from unittest.mock import patch, MagicMock + +import pytest + + +def _reload_constants(): + """Reload hermes_constants to get a fresh apply_ipv4_preference.""" + import hermes_constants + importlib.reload(hermes_constants) + return hermes_constants + + +class TestApplyIPv4Preference: + """Tests for apply_ipv4_preference().""" + + def setup_method(self): + """Save the original getaddrinfo before each test.""" + self._original = socket.getaddrinfo + + def teardown_method(self): + """Restore the original getaddrinfo after each test.""" + socket.getaddrinfo = self._original + + def test_noop_when_force_false(self): + """No patch when force=False.""" + from hermes_constants import apply_ipv4_preference + original = socket.getaddrinfo + apply_ipv4_preference(force=False) + assert socket.getaddrinfo is original + + def test_patches_getaddrinfo_when_forced(self): + """Patches socket.getaddrinfo when force=True.""" + from hermes_constants import apply_ipv4_preference + original = socket.getaddrinfo + apply_ipv4_preference(force=True) + assert socket.getaddrinfo is not original + assert getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False) is True + + def test_double_patch_is_safe(self): + """Calling apply twice doesn't double-wrap.""" + from hermes_constants import apply_ipv4_preference + apply_ipv4_preference(force=True) + first_patch = socket.getaddrinfo + apply_ipv4_preference(force=True) + assert socket.getaddrinfo is first_patch + + def test_af_unspec_becomes_af_inet(self): + """AF_UNSPEC (default) calls get rewritten to AF_INET.""" + from hermes_constants import apply_ipv4_preference + + calls = [] + original = socket.getaddrinfo + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + calls.append(family) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + # Call with default family (AF_UNSPEC = 0) + socket.getaddrinfo("example.com", 80) + assert calls[-1] == socket.AF_INET, "AF_UNSPEC should be rewritten to AF_INET" + + def test_explicit_family_preserved(self): + """Explicit AF_INET6 requests are not intercepted.""" + from hermes_constants import apply_ipv4_preference + + calls = [] + original = socket.getaddrinfo + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + calls.append(family) + return [(family, socket.SOCK_STREAM, 6, "", ("::1", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + socket.getaddrinfo("example.com", 80, family=socket.AF_INET6) + assert calls[-1] == socket.AF_INET6, "Explicit AF_INET6 should pass through" + + def test_fallback_on_gaierror(self): + """Falls back to AF_UNSPEC if AF_INET resolution fails.""" + from hermes_constants import apply_ipv4_preference + + call_families = [] + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + call_families.append(family) + if family == socket.AF_INET: + raise socket.gaierror("No A record") + # AF_UNSPEC fallback returns IPv6 + return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + result = socket.getaddrinfo("ipv6only.example.com", 80) + # Should have tried AF_INET first, then fallen back to AF_UNSPEC + assert call_families == [socket.AF_INET, 0] + assert result[0][0] == socket.AF_INET6 + + +class TestConfigDefault: + """Verify network section exists in DEFAULT_CONFIG.""" + + def test_network_section_in_default_config(self): + from hermes_cli.config import DEFAULT_CONFIG + assert "network" in DEFAULT_CONFIG + assert DEFAULT_CONFIG["network"]["force_ipv4"] is False diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py index 411f95f7e03..64d198970cb 100644 --- a/tests/tools/test_notify_on_complete.py +++ b/tests/tools/test_notify_on_complete.py @@ -289,3 +289,62 @@ class TestCodeExecutionBlocked: def test_notify_on_complete_blocked_in_sandbox(self): from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS + + +# ========================================================================= +# Completion consumed suppression +# ========================================================================= + +class TestCompletionConsumed: + """Test that wait/poll/log suppress redundant completion notifications.""" + + def test_wait_marks_completion_consumed(self, registry): + """wait() returning exited status marks session as consumed.""" + s = _make_session(sid="proc_wait", notify_on_complete=True, output="done") + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + # Notification is in the queue + assert not registry.completion_queue.empty() + assert not registry.is_completion_consumed("proc_wait") + + # Agent calls wait() — gets the result directly + result = registry.wait("proc_wait", timeout=1) + assert result["status"] == "exited" + + # Now the completion is marked as consumed + assert registry.is_completion_consumed("proc_wait") + + def test_poll_marks_completion_consumed(self, registry): + """poll() returning exited status marks session as consumed.""" + s = _make_session(sid="proc_poll", notify_on_complete=True, output="done") + s.exited = True + s.exit_code = 0 + registry._finished[s.id] = s + + result = registry.poll("proc_poll") + assert result["status"] == "exited" + assert registry.is_completion_consumed("proc_poll") + + def test_log_marks_completion_consumed(self, registry): + """read_log() on exited session marks as consumed.""" + s = _make_session(sid="proc_log", notify_on_complete=True, output="line1\nline2") + s.exited = True + s.exit_code = 0 + registry._finished[s.id] = s + + result = registry.read_log("proc_log") + assert result["status"] == "exited" + assert registry.is_completion_consumed("proc_log") + + def test_running_process_not_consumed(self, registry): + """poll() on a still-running process does not mark as consumed.""" + s = _make_session(sid="proc_running", notify_on_complete=True, output="partial") + registry._running[s.id] = s + + result = registry.poll("proc_running") + assert result["status"] == "running" + assert not registry.is_completion_consumed("proc_running") diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 80c88e35346..d5c81ad7a86 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -6,12 +6,15 @@ """ import json +import logging import os import re import sys from pathlib import Path from typing import Any, Dict, List, Optional +logger = logging.getLogger(__name__) + # Import from cron module (will be available when properly installed) sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -68,11 +71,17 @@ def _origin_from_env() -> Optional[Dict[str, str]]: origin_platform = get_session_env("HERMES_SESSION_PLATFORM") origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID") if origin_platform and origin_chat_id: + thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None + if thread_id: + logger.debug( + "Cron origin captured thread_id=%s for %s:%s", + thread_id, origin_platform, origin_chat_id, + ) return { "platform": origin_platform, "chat_id": origin_chat_id, "chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None, - "thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None, + "thread_id": thread_id, } return None @@ -456,7 +465,7 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: }, "deliver": { "type": "string", - "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, weixin, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, wecom_callback, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" + "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting." }, "skills": { "type": "array", diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f00701cd94a..73ba81272fc 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -25,6 +25,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional +from toolsets import TOOLSETS + # Tools that children must never have access to DELEGATE_BLOCKED_TOOLS = frozenset([ @@ -35,6 +37,18 @@ "execute_code", # children should reason step-by-step, not write scripts ]) +# Build a description fragment listing toolsets available for subagents. +# Excludes toolsets where ALL tools are blocked, composite/platform toolsets +# (hermes-* prefixed), and scenario toolsets. +_EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"}) +_SUBAGENT_TOOLSETS = sorted( + name for name, defn in TOOLSETS.items() + if name not in _EXCLUDED_TOOLSET_NAMES + and not name.startswith("hermes-") + and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", [])) +) +_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS) + _DEFAULT_MAX_CONCURRENT_CHILDREN = 3 MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2) @@ -999,9 +1013,10 @@ def _load_config() -> dict: "description": ( "Toolsets to enable for this subagent. " "Default: inherits your enabled toolsets. " + f"Available toolsets: {_TOOLSET_LIST_STR}. " "Common patterns: ['terminal', 'file'] for code work, " - "['web'] for research, ['terminal', 'file', 'web'] for " - "full-stack tasks." + "['web'] for research, ['browser'] for web interaction, " + "['terminal', 'file', 'web'] for full-stack tasks." ), }, "tasks": { @@ -1014,7 +1029,7 @@ def _load_config() -> dict: "toolsets": { "type": "array", "items": {"type": "string"}, - "description": "Toolsets for this specific task. Use 'web' for network access, 'terminal' for shell.", + "description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.", }, "acp_command": { "type": "string", diff --git a/tools/process_registry.py b/tools/process_registry.py index 044a4e77674..a5dbc3b1bd4 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -136,6 +136,10 @@ def __init__(self): import queue as _queue_mod self.completion_queue: _queue_mod.Queue = _queue_mod.Queue() + # Track sessions whose completion was already consumed by the agent + # via wait/poll/log. Drain loops skip notifications for these. + self._completion_consumed: set = set() + @staticmethod def _clean_shell_noise(text: str) -> str: """Strip shell startup warnings from the beginning of output.""" @@ -613,6 +617,10 @@ def _move_to_finished(self, session: ProcessSession): # ----- Query Methods ----- + def is_completion_consumed(self, session_id: str) -> bool: + """Check if a completion notification was already consumed via wait/poll/log.""" + return session_id in self._completion_consumed + def get(self, session_id: str) -> Optional[ProcessSession]: """Get a session by ID (running or finished).""" with self._lock: @@ -640,6 +648,7 @@ def poll(self, session_id: str) -> dict: } if session.exited: result["exit_code"] = session.exit_code + self._completion_consumed.add(session_id) if session.detached: result["detached"] = True result["note"] = "Process recovered after restart -- output history unavailable" @@ -665,13 +674,16 @@ def read_log(self, session_id: str, offset: int = 0, limit: int = 200) -> dict: else: selected = lines[offset:offset + limit] - return { + result = { "session_id": session.id, "status": "exited" if session.exited else "running", "output": "\n".join(selected), "total_lines": total_lines, "showing": f"{len(selected)} lines", } + if session.exited: + self._completion_consumed.add(session_id) + return result def wait(self, session_id: str, timeout: int = None) -> dict: """ @@ -714,6 +726,7 @@ def wait(self, session_id: str, timeout: int = None) -> dict: while time.monotonic() < deadline: session = self._refresh_detached_session(session) if session.exited: + self._completion_consumed.add(session_id) result = { "status": "exited", "exit_code": session.exit_code, diff --git a/tools/test_tirith_security_fix.py b/tools/test_tirith_security_fix.py new file mode 100644 index 00000000000..aa2f9abcd9d --- /dev/null +++ b/tools/test_tirith_security_fix.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Test script for tirith_security.py Windows compatibility fix. + +Tests: +1. _detect_target() returns correct platform triple +2. _get_archive_name() uses .zip for Windows, .tar.gz otherwise +3. ZIP extraction path resolution is correct (src_base = src) +4. dest path uses correct binary name (tirith.exe vs tirith) + +Run from hermes-agent root: + python3 tools/test_tirith_security_fix.py +""" + +import os +import sys +import tempfile +import zipfile +import tarfile +import platform +import stat + +# Add tools dir to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hermes-agent", "tools")) + +# Import the module functions we need to test +# We test via mock since tirith_security imports hermes paths +import importlib.util + +spec = importlib.util.spec_from_file_location( + "tirith_security", + os.path.join(os.path.dirname(__file__), "tirith_security.py") +) +ts = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ts) + +def test_detect_target(): + """Test _detect_target() returns correct platform triple.""" + print("\n=== Test 1: _detect_target() ===") + + target = ts._detect_target() + system = platform.system() + + print(f" Detected: {target}") + print(f" Platform: {system}/{platform.machine()}") + + if system == "Windows": + # _detect_target maps AMD64 -> x86_64, ARM64 -> aarch64 + arch_map = {"amd64": "x86_64", "aarch64": "aarch64"} + arch = arch_map.get(platform.machine().lower(), platform.machine().lower()) + expected = f"{arch}-pc-windows-msvc" + assert target == expected, f"Expected {expected}, got {target}" + elif system == "Linux": + arch = "x86_64" if platform.machine().lower() in ("x86_64", "amd64") else "aarch64" + assert target == f"{arch}-unknown-linux-gnu", f"Unexpected target: {target}" + elif system == "Darwin": + arch = "x86_64" if platform.machine().lower() in ("x86_64", "amd64") else "aarch64" + assert target == f"{arch}-apple-darwin", f"Unexpected target: {target}" + + print(" ✅ PASS") + return target + + +def test_archive_name(target): + """Test archive name uses .zip for Windows, .tar.gz otherwise.""" + print("\n=== Test 2: Archive name ===") + + is_windows = target.endswith("-pc-windows-msvc") + archive_name = f"tirith-{target}.tar.gz" + if is_windows: + archive_name = f"tirith-{target}.zip" + + print(f" Target: {target}") + print(f" Archive: {archive_name}") + + if is_windows: + assert archive_name == f"tirith-{target}.zip", f"Windows should use .zip, got {archive_name}" + else: + assert archive_name == f"tirith-{target}.tar.gz", f"Linux/macOS should use .tar.gz, got {archive_name}" + + print(" ✅ PASS") + + +def test_zip_extraction_path(): + """Test ZIP extraction correctly resolves nested member paths.""" + print("\n=== Test 3: ZIP extraction path resolution ===") + + # Simulate a ZIP with nested path (common in GitHub releases) + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = os.path.join(tmpdir, "test.zip") + + # Create a test ZIP with nested tirith.exe + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("tirith.exe", b"fake binary") + zf.writestr("nested/path/tirith.exe", b"fake binary nested") + + # Simulate the extraction logic from _install_tirith + extracted_path = None + with zipfile.ZipFile(zip_path, "r") as zf: + for member in zf.namelist(): + if member == "tirith.exe" or member.endswith("/tirith.exe"): + if ".." in member: + continue + zf.extract(member, tmpdir) + extracted_path = os.path.join(tmpdir, member) + break + + print(f" Extracted to: {extracted_path}") + assert extracted_path is not None, "Should find tirith.exe" + assert os.path.exists(extracted_path), f"Extracted file should exist: {extracted_path}" + + # Verify src_base should equal extracted_path, not a hardcoded path + src_base_wrong = os.path.join(tmpdir, "tirith.exe") + print(f" src_base (correct): {extracted_path}") + print(f" src_base (wrong): {src_base_wrong}") + + # If nested, wrong approach would fail + if "nested" in extracted_path: + assert extracted_path != src_base_wrong, "Should use actual extracted path" + print(" ✅ PASS (nested path handled correctly)") + else: + print(" ✅ PASS (flat path works with both)") + + +def test_dest_binary_name(): + """Test destination binary name is tirith.exe on Windows, tirith otherwise.""" + print("\n=== Test 4: Destination binary name ===") + + test_cases = [ + ("x86_64-pc-windows-msvc", "tirith.exe"), + ("aarch64-pc-windows-msvc", "tirith.exe"), + ("x86_64-unknown-linux-gnu", "tirith"), + ("aarch64-apple-darwin", "tirith"), + ] + + for target, expected in test_cases: + is_windows = target.endswith("-pc-windows-msvc") + dest = f"tirith.exe" if is_windows else "tirith" + print(f" {target} → {dest}") + assert dest == expected, f"{target} should give {expected}, got {dest}" + + print(" ✅ PASS") + + +def test_chmod_not_called_on_windows(): + """Test chmod is NOT called for Windows binaries.""" + print("\n=== Test 5: No chmod on Windows ===") + + # Simulate the logic from _install_tirith + test_cases = [ + ("x86_64-pc-windows-msvc", False), # is_windows = True, should NOT chmod + ("aarch64-pc-windows-msvc", False), + ("x86_64-unknown-linux-gnu", True), # is_windows = False, should chmod + ("aarch64-apple-darwin", True), + ] + + for target, should_chmod in test_cases: + is_windows = target.endswith("-pc-windows-msvc") + would_chmod = not is_windows # actual logic from code + + print(f" {target}: chmod={would_chmod} (expected: {should_chmod})") + assert would_chmod == should_chmod, f"{target}: chmod should be {should_chmod}, got {would_chmod}" + + print(" ✅ PASS") + + +if __name__ == "__main__": + print("=" * 60) + print("tirith_security.py Windows Compatibility Fix - Test Suite") + print("=" * 60) + + try: + target = test_detect_target() + test_archive_name(target) + test_zip_extraction_path() + test_dest_binary_name() + test_chmod_not_called_on_windows() + + print("\n" + "=" * 60) + print("✅ ALL TESTS PASSED") + print("=" * 60) + sys.exit(0) + + except AssertionError as e: + print(f"\n❌ TEST FAILED: {e}") + sys.exit(1) + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index b3055944e33..7849f9f99ba 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -190,6 +190,8 @@ def _detect_target() -> str | None: plat = "apple-darwin" elif system == "Linux": plat = "unknown-linux-gnu" + elif system == "Windows": + plat = "pc-windows-msvc" else: return None @@ -295,6 +297,9 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: return None, "unsupported_platform" archive_name = f"tirith-{target}.tar.gz" + is_windows = target.endswith("-pc-windows-msvc") + if is_windows: + archive_name = f"tirith-{target}.zip" base_url = f"https://github.com/{_REPO}/releases/latest/download" tmpdir = tempfile.mkdtemp(prefix="tirith-install-") @@ -345,23 +350,38 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: if not _verify_checksum(archive_path, checksums_path, archive_name): return None, "checksum_failed" - with tarfile.open(archive_path, "r:gz") as tar: - # Extract only the tirith binary (safety: reject paths with ..) - for member in tar.getmembers(): - if member.name == "tirith" or member.name.endswith("/tirith"): - if ".." in member.name: - continue - member.name = "tirith" - tar.extract(member, tmpdir) - break - else: - log("tirith binary not found in archive") - return None, "binary_not_in_archive" - - src = os.path.join(tmpdir, "tirith") - dest = os.path.join(_hermes_bin_dir(), "tirith") - shutil.move(src, dest) - os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + if is_windows: + import zipfile + with zipfile.ZipFile(archive_path, "r") as zf: + for member in zf.namelist(): + if member == "tirith.exe" or member.endswith("/tirith.exe"): + if ".." in member: + continue + zf.extract(member, tmpdir) + src = os.path.join(tmpdir, member) + break + else: + log("tirith binary not found in archive") + return None, "binary_not_in_archive" + src_base = src # reuse the path computed at extraction time + else: + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + if member.name == "tirith" or member.name.endswith("/tirith"): + if ".." in member.name: + continue + member.name = "tirith" + tar.extract(member, tmpdir) + break + else: + log("tirith binary not found in archive") + return None, "binary_not_in_archive" + src_base = os.path.join(tmpdir, "tirith") + + dest = os.path.join(_hermes_bin_dir(), "tirith.exe" if is_windows else "tirith") + shutil.move(src_base, dest) + if not is_windows: + os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only" logger.info("tirith installed to %s (%s)", dest, verification) diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md index 6322c725b0d..5cf2f8c96fa 100644 --- a/website/docs/guides/migrate-from-openclaw.md +++ b/website/docs/guides/migrate-from-openclaw.md @@ -23,7 +23,7 @@ hermes claw migrate --preset full --yes The migration always shows a full preview of what will be imported before making any changes. Review the list, then confirm to proceed. -Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moldbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). +Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moltbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moltbot.json`). ## Options @@ -234,7 +234,7 @@ The migration resolves all three formats. For env templates and SecretRef object ### "OpenClaw directory not found" -The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. +The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moltbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. ### "No provider API keys found" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 12394ea44e9..07a2f76eb8f 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -660,7 +660,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`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`). +Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moltbot`) and config filenames (`clawdbot.json`, `moltbot.json`). | Option | Description | |--------|-------------| diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index bec5ff1c373..ff832a0361d 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -277,6 +277,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` | | `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) | | `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) | +| `MATRIX_RECOVERY_KEY` | Recovery key for cross-signing verification after device key rotation. Recommended for E2EE setups with cross-signing enabled. | | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) | | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) | | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) | diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index ac4bad239fe..4d9783d402b 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -31,12 +31,25 @@ Set it to `false` only if you explicitly want one shared conversation per chat. ## Step 1: Create a Feishu / Lark App +### Recommended: Scan-to-Create (one command) + +```bash +hermes gateway setup +``` + +Select **Feishu / Lark** and scan the QR code with your Feishu or Lark mobile app. Hermes will automatically create a bot application with the correct permissions and save the credentials. + +### Alternative: Manual Setup + +If scan-to-create is not available, the wizard falls back to manual input: + 1. Open the Feishu or Lark developer console: - 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. +5. Run `hermes gateway setup`, select **Feishu / Lark**, and enter the credentials when prompted. :::warning Keep the App Secret private. Anyone with it can impersonate your app. diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index ccde0740d6b..de03ff81783 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -272,6 +272,18 @@ When E2EE is enabled, Hermes: - Decrypts incoming messages and encrypts outgoing messages automatically - Auto-joins encrypted rooms when invited +### Cross-Signing Verification (Recommended) + +If your Matrix account has cross-signing enabled (the default in Element), set the recovery key so the bot can self-sign its device on startup. Without this, other Matrix clients may refuse to share encryption sessions with the bot after a device key rotation. + +```bash +MATRIX_RECOVERY_KEY=EsT... your recovery key here +``` + +**Where to find it:** In Element, go to **Settings** → **Security & Privacy** → **Encryption** → your recovery key (also called the "Security Key"). This is the key you were asked to save when you first set up cross-signing. + +On each startup, if `MATRIX_RECOVERY_KEY` is set, Hermes imports cross-signing keys from the homeserver's secure secret storage and signs the current device. This is idempotent and safe to leave enabled permanently. + :::warning If you delete the `~/.hermes/platforms/matrix/store/` directory, the bot loses its encryption keys. You'll need to verify the device again in your Matrix client. Back up this directory if you want to preserve encrypted sessions. ::: @@ -374,7 +386,7 @@ changed identity keys for the same device as suspicious. -d '{ "type": "m.login.password", "identifier": {"type": "m.id.user", "user": "@hermes:your-server.org"}, - "password": "your-password", + "password": "***", "initial_device_display_name": "Hermes Agent" }' ``` @@ -388,17 +400,27 @@ changed identity keys for the same device as suspicious. rm -f ~/.hermes/platforms/matrix/store/crypto_store.* ``` -3. **Force your Matrix client to rotate the encryption session**. In Element, +3. **Set your recovery key** (if you use cross-signing — most Element users do). Add to `~/.hermes/.env`: + + ```bash + MATRIX_RECOVERY_KEY=EsT... your recovery key here + ``` + + This lets the bot self-sign with cross-signing keys on startup, so Element trusts the new device immediately. Without this, Element may see the new device as unverified and refuse to share encryption sessions. Find your recovery key in Element under **Settings** → **Security & Privacy** → **Encryption**. + +4. **Force your Matrix client to rotate the encryption session**. In Element, open the DM room with the bot and type `/discardsession`. This forces Element to create a new encryption session and share it with the bot's new device. -4. **Restart the gateway**: +5. **Restart the gateway**: ```bash hermes gateway run ``` -5. **Send a new message**. The bot should decrypt and respond normally. + If `MATRIX_RECOVERY_KEY` is set, you should see `Matrix: cross-signing verified via recovery key` in the logs. + +6. **Send a new message**. The bot should decrypt and respond normally. :::note After migration, messages sent *before* the upgrade cannot be decrypted -- the old