diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index cbf7d15b3b86..fce98cf54e6c 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -110,7 +110,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str: if tool_name == "web_extract": urls = args.get("urls", []) if urls: - return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") + first = urls[0] + if isinstance(first, dict): + first = first.get("url") or first.get("href") or "?" + elif not isinstance(first, str): + first = "?" + return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") return "web extract" if tool_name == "process": action = str(args.get("action") or "").strip() or "manage" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 3e5cd63536bb..e849b955d19a 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1816,13 +1816,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Swap core runtime fields ── agent.model = new_model agent.provider = new_provider - # Use new base_url when provided; only fall back to current when the - # new provider genuinely has no endpoint (e.g. native SDK providers). - # Without this guard the old provider's URL (e.g. Ollama's localhost - # address) would persist silently after switching to a cloud provider - # that returns an empty base_url string. + # Use the new base_url when provided. When it's empty AND the + # provider is actually changing, do NOT fall back to the current + # (old provider's) URL — that silently pairs the new provider label + # with the previous provider's endpoint (e.g. new_provider=minimax + # paired with the leftover api.githubcopilot.com URL), and every + # request after the switch 400s at the wrong host. This mismatched + # pair also gets snapshotted into _primary_runtime below, so it + # keeps re-applying on every subsequent turn until a full restart. + # Fail loud instead: the caller (model_switch.switch_model()) + # already resolves base_url for every real provider, so an empty + # value here means resolution failed upstream, not that the + # provider genuinely has none. Re-selecting the SAME provider with + # an empty base_url (e.g. a credential-only refresh) is still fine + # to keep the current URL. See #47828. + old_norm_provider = (old_provider or "").strip().lower() + new_norm_provider = (new_provider or "").strip().lower() if base_url: agent.base_url = base_url + elif old_norm_provider != new_norm_provider: + raise ValueError( + f"switch_model: no base_url resolved for provider " + f"'{new_provider}' (switching from '{old_provider}'); " + "refusing to keep the previous provider's endpoint" + ) agent.api_mode = api_mode # Invalidate transport cache — new api_mode may need a different transport if hasattr(agent, "_transport_cache"): @@ -1936,6 +1953,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) if _sm_timeout is not None: agent._client_kwargs["timeout"] = _sm_timeout + # Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer, + # X-Title) that were lost when _client_kwargs was rebuilt from + # scratch. Without this, model switches clear attribution headers + # and OpenRouter logs show "Unknown" for subsequent requests. + agent._apply_client_headers_for_base_url(effective_base) agent.client = agent._create_openai_client( dict(agent._client_kwargs), reason="switch_model", @@ -3139,21 +3161,20 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in -def force_close_tcp_sockets(client: Any) -> int: - """Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs. +def force_close_tcp_sockets(client: Any, *, release_fds: bool = False) -> int: + """Abort in-flight TCP I/O by shutting down pool sockets. When a provider drops a connection mid-stream — or the user issues an interrupt — we want to unblock httpx's reader/writer immediately rather than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)`` achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF - or ``EPIPE``, but does NOT release the file descriptor. + or ``EPIPE``. - Historically this helper also called ``socket.close()`` so the FD got - released immediately, but that's unsafe when (as is the case for both the - interrupt-abort path and stale-call kill path) the helper runs on a - different thread than the one driving the request: + By default (``release_fds=False``) this helper does **not** call + ``socket.close()`` / release the FD. That default is load-bearing for + cross-thread abort paths (#29507): - * The Python ``socket.socket`` we close here is the SAME object held by + * The Python ``socket.socket`` we close is the SAME object held by httpx's pool, so closing it via Python sets its ``_fd`` to -1 and future operations on that Python object fail safely. * BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``) @@ -3165,15 +3186,20 @@ def force_close_tcp_sockets(client: Any) -> int: wrong file (issue #29507: 24-byte TLS application-data record clobbering SQLite header bytes 5..28). - The fix is to let the owning thread own the close. ``shutdown()`` from any - thread is FD-safe; ``close()`` is not. The httpx connection's own close - path — which runs from the worker thread when it unwinds — will release - the FD via the same ``socket.socket`` object, and because Python's socket - close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there - is no FD-aliasing window when only one thread closes. + ``shutdown()`` from any thread is FD-safe; ``close()`` is not when a + stranger thread still has the BIO holding the raw FD. + + When the **owning** thread is disposing of a client that is no longer + shared (``_close_openai_client`` after replace / request-complete), pass + ``release_fds=True``. httpx's own ``client.close()`` does not reliably + ``os.close()`` sockets that were already ``shutdown()``'d, so without an + explicit ``sock.close()`` those FDs stay in kernel CLOSED state forever + and accumulate under long-lived gateways (issue #61979 — ~1 CLOSED fd + per ~6 minutes through a local proxy path). - Returns the number of sockets shut down. (Field kept as - ``tcp_force_closed=N`` in the log line for backwards-compatible parsing.) + Returns the number of sockets shut down (and optionally closed). Field + kept as ``tcp_force_closed=N`` in log lines for backwards-compatible + parsing. """ import socket as _socket @@ -3185,7 +3211,13 @@ def force_close_tcp_sockets(client: Any) -> int: except OSError: # Already shut down / not connected / FD invalid — all benign. pass - # IMPORTANT (#29507): do NOT call sock.close() here. See docstring. + # IMPORTANT (#29507): never release FDs from stranger-thread + # abort paths. Only the owning-thread close path may opt in. + if release_fds: + try: + sock.close() + except OSError: + pass shutdown_count += 1 except Exception as exc: _ra().logger.debug("Force-close TCP sockets sweep error: %s", exc) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 623560250df4..215868306348 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2102,7 +2102,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]: if isinstance(content, list): converted_blocks = _convert_content_to_anthropic(content) if not converted_blocks or all( - b.get("text", "").strip() == "" + (b.get("text") or "").strip() == "" for b in converted_blocks if isinstance(b, dict) and b.get("type") == "text" ): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b1d9a8135e6f..d8ff9204daed 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4716,8 +4716,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", if custom_entry is None: custom_entry = _get_named_custom_provider(provider) if custom_entry: - custom_base = custom_entry.get("base_url", "").strip() - custom_key = custom_entry.get("api_key", "").strip() + custom_base = (custom_entry.get("base_url") or "").strip() + custom_key = (custom_entry.get("api_key") or "").strip() custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip() if not custom_key and custom_key_env: custom_key = os.getenv(custom_key_env, "").strip() diff --git a/agent/background_review.py b/agent/background_review.py index 3f4e5efcd376..bf78f679236d 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: "api_key": parent_runtime.get("api_key") or None, "base_url": parent_runtime.get("base_url") or None, "api_mode": parent_api_mode, + "credential_pool": getattr(agent, "_credential_pool", None), + "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), + "max_tokens": getattr(agent, "max_tokens", None), + "command": getattr(agent, "acp_command", None), + "args": list(getattr(agent, "acp_args", []) or []), "routed": False, } try: @@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: ) return { "provider": rp.get("provider") or task_provider, - "model": task_model, + "model": rp.get("model") or task_model, "api_key": rp.get("api_key"), "base_url": rp.get("base_url"), "api_mode": rp.get("api_mode"), + "credential_pool": rp.get("credential_pool"), + "request_overrides": dict(rp.get("request_overrides") or {}), + "max_tokens": rp.get("max_output_tokens"), + "command": rp.get("command"), + "args": list(rp.get("args") or []), "routed": True, } except Exception as e: @@ -680,6 +690,12 @@ def _bg_review_auto_deny(command, description, **kwargs): # Match parent's toolset config so ``tools[]`` is byte-identical # in the request body — Anthropic's cache key includes it. # (The runtime whitelist below still restricts dispatch.) + _fork_kwargs: Dict[str, Any] = {} + if isinstance(_rt.get("max_tokens"), int): + _fork_kwargs["max_tokens"] = _rt["max_tokens"] + if isinstance(_rt.get("command"), str) and _rt["command"]: + _fork_kwargs["acp_command"] = _rt["command"] + _fork_kwargs["acp_args"] = _rt.get("args") or [] review_agent = AIAgent( model=_rt.get("model") or agent.model, max_iterations=16, @@ -689,11 +705,13 @@ def _bg_review_auto_deny(command, description, **kwargs): api_mode=_rt.get("api_mode"), base_url=_rt.get("base_url") or None, api_key=_rt.get("api_key") or None, - credential_pool=getattr(agent, "_credential_pool", None), + credential_pool=_rt.get("credential_pool"), + request_overrides=_rt.get("request_overrides") or {}, parent_session_id=agent.session_id, enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), skip_memory=True, + **_fork_kwargs, ) review_agent._memory_write_origin = "background_review" review_agent._memory_write_context = "background_review" diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index c3df449bdfb7..4e4aa455a600 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -164,6 +164,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]: return None +def _provider_preferences_for_agent(agent) -> Dict[str, Any]: + """Build the validated provider-routing object shared by request paths.""" + preferences: Dict[str, Any] = {} + if agent.providers_allowed: + preferences["only"] = agent.providers_allowed + if agent.providers_ignored: + preferences["ignore"] = agent.providers_ignored + if agent.providers_order: + preferences["order"] = agent.providers_order + provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) + if provider_sort: + preferences["sort"] = provider_sort + if agent.provider_require_parameters: + preferences["require_parameters"] = True + if agent.provider_data_collection: + preferences["data_collection"] = agent.provider_data_collection + return preferences + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -801,21 +820,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _omit_temp = False _fixed_temp = None - # Provider preferences (OpenRouter-style) - _prefs: Dict[str, Any] = {} - if agent.providers_allowed: - _prefs["only"] = agent.providers_allowed - if agent.providers_ignored: - _prefs["ignore"] = agent.providers_ignored - if agent.providers_order: - _prefs["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - _prefs["sort"] = _provider_sort - if agent.provider_require_parameters: - _prefs["require_parameters"] = True - if agent.provider_data_collection: - _prefs["data_collection"] = agent.provider_data_collection + # Provider preferences (aggregator profile decides whether to emit them). + _prefs = _provider_preferences_for_agent(agent) # Anthropic-compatible max-output fallback (last resort only — applied in # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding @@ -1690,18 +1696,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if _lm_reasoning_effort is not None: summary_kwargs["reasoning_effort"] = _lm_reasoning_effort - # Include provider routing preferences - provider_preferences = {} - if agent.providers_allowed: - provider_preferences["only"] = agent.providers_allowed - if agent.providers_ignored: - provider_preferences["ignore"] = agent.providers_ignored - if agent.providers_order: - provider_preferences["order"] = agent.providers_order - _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) - if _provider_sort: - provider_preferences["sort"] = _provider_sort - if provider_preferences and ( + # Merge the profile's canonical body even when routing is unset: + # profiles may always emit required metadata such as Portal tags. + provider_preferences = _provider_preferences_for_agent(agent) + profile_extra_body = {} + try: + from providers import get_provider_profile + + provider_profile = get_provider_profile(agent.provider) + if provider_profile is not None: + profile_extra_body = provider_profile.build_extra_body( + session_id=getattr(agent, "session_id", None), + provider_preferences=provider_preferences or None, + model=agent.model, + base_url=agent.base_url, + reasoning_config=agent.reasoning_config, + ) + except Exception: + pass + + if profile_extra_body: + summary_extra_body.update(profile_extra_body) + if provider_preferences and "provider" not in profile_extra_body and ( (agent.provider or "").strip().lower() == "openrouter" or agent._is_openrouter_url() ): diff --git a/agent/coding_context.py b/agent/coding_context.py index 00f6d996d478..db38ab3daa8a 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -56,6 +56,7 @@ import os import re import subprocess +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, Optional @@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]: """ current = cwd.resolve() home = _home() + # Shared world-writable temp roots are never project roots: a stray + # manifest in /tmp (left by any process) must not flip every session + # whose cwd lives under the temp dir into the coding posture. Same + # reasoning as the $HOME skip below. + try: + temp_root = Path(tempfile.gettempdir()).resolve() + except Exception: + temp_root = None for depth, parent in enumerate([current, *current.parents]): if depth > 6: break - if parent == home: + if parent == home or (temp_root is not None and parent == temp_root): continue for marker in _PROJECT_MARKERS: if (parent / marker).exists(): diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 4295bdad6022..ce62ffcde1a9 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -613,6 +613,11 @@ def run_conversation( truncated_response_parts: List[str] = [] compression_attempts = 0 _turn_exit_reason = "unknown" # Diagnostic: why the loop ended + # Last composed answer intentionally held back by a verification gate. If + # that continuation consumes the remaining budget, this is the best + # user-facing result available; it must not be confused with error or + # recovery text produced by unrelated exit paths. + _pending_verification_response = None # Per-turn tally of consecutive successful credential-pool token refreshes, # keyed by (provider, pool-entry-id). A persistent upstream 401 lets @@ -5099,6 +5104,10 @@ def _perform_api_call(next_api_kwargs): } messages.append(continue_msg) agent._session_messages = messages + # An acknowledgment is explicitly non-final. Do not let its + # text suppress iteration-limit summarization if this + # continuation consumes the remaining budget. + final_response = None continue codex_ack_continuations = 0 @@ -5173,6 +5182,12 @@ def _perform_api_call(next_api_kwargs): # terminal. Keep a debug breadcrumb in agent.log for tracing. logger.debug("verification stop-loop nudge issued (attempt %d)", agent._verification_stop_nudges) + # Keep the attempted answer only as an explicit fallback for + # continuation-budget exhaustion. ``final_response`` itself + # must be cleared so the finalizer can distinguish this gate + # from unrelated error/recovery exits. (#61631) + _pending_verification_response = final_response + final_response = None continue # User verification-loop gate: when the agent edited code this @@ -5224,6 +5239,8 @@ def _perform_api_call(next_api_kwargs): agent._session_messages = messages logger.debug("pre_verify nudge issued (attempt %d)", agent._pre_verify_nudges) + _pending_verification_response = final_response + final_response = None continue messages.append(final_msg) @@ -5308,6 +5325,7 @@ def _perform_api_call(next_api_kwargs): original_user_message=original_user_message, _should_review_memory=_should_review_memory, _turn_exit_reason=_turn_exit_reason, + _pending_verification_response=_pending_verification_response, ) diff --git a/agent/curator.py b/agent/curator.py index c13a36ecbbd3..ada93248e246 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]: class _ReviewRuntimeBinding(NamedTuple): - """Provider/model for the curator review fork plus optional per-slot overrides.""" + """Provider/model for the curator review fork plus per-slot overrides.""" provider: str model: str explicit_api_key: Optional[str] explicit_base_url: Optional[str] + request_overrides: Dict[str, Any] + + +def _merge_request_overrides( + runtime_overrides: Any, + slot_extra_body: Any, +) -> Dict[str, Any]: + """Merge resolver metadata with task-local request body fields.""" + merged = dict(runtime_overrides or {}) + if isinstance(slot_extra_body, dict) and slot_extra_body: + extra_body = dict(merged.get("extra_body") or {}) + extra_body.update(slot_extra_body) + merged["extra_body"] = extra_body + return merged DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days @@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding: _task_model, _strip_aux_credential(_cur_task.get("api_key")), _strip_aux_credential(_cur_task.get("base_url")), + _merge_request_overrides({}, _cur_task.get("extra_body")), ) # 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification) @@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding: str(_legacy_model), _strip_aux_credential(_legacy.get("api_key")), _strip_aux_credential(_legacy.get("base_url")), + _merge_request_overrides({}, _legacy.get("extra_body")), ) # 3. Fall through to the main chat model - return _ReviewRuntimeBinding(_main_provider, _main_model, None, None) + return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {}) def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]: @@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _base_url = None _api_mode = None _resolved_provider = None + _credential_pool = None + _request_overrides: Dict[str, Any] = {} + _max_tokens = None + _acp_command = None + _acp_args = None _model_name = "" try: from hermes_cli.config import load_config @@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _base_url = _rp.get("base_url") _api_mode = _rp.get("api_mode") _resolved_provider = _rp.get("provider") or _provider + _credential_pool = _rp.get("credential_pool") + _request_overrides = _merge_request_overrides( + _rp.get("request_overrides"), + _binding.request_overrides.get("extra_body"), + ) + _max_tokens = _rp.get("max_output_tokens") + _acp_command = _rp.get("command") + _acp_args = list(_rp.get("args") or []) + if isinstance(_rp.get("model"), str) and _rp["model"].strip(): + _model_name = _rp["model"].strip() except Exception as e: logger.debug("Curator provider resolution failed: %s", e, exc_info=True) @@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: review_agent = None try: + _agent_kwargs: Dict[str, Any] = {} + if isinstance(_max_tokens, int): + _agent_kwargs["max_tokens"] = _max_tokens + if isinstance(_acp_command, str) and _acp_command: + _agent_kwargs["acp_command"] = _acp_command + _agent_kwargs["acp_args"] = _acp_args or [] review_agent = AIAgent( model=_model_name, provider=_resolved_provider, api_key=_api_key, base_url=_base_url, api_mode=_api_mode, + credential_pool=_credential_pool, + request_overrides=_request_overrides, + **_agent_kwargs, # Umbrella-building over a large skill collection is worth a # high iteration ceiling — the pass typically takes 50-100 # API calls against hundreds of candidate skills. The diff --git a/agent/display.py b/agent/display.py index 5a16a77d00de..66872c35d667 100644 --- a/agent/display.py +++ b/agent/display.py @@ -27,6 +27,14 @@ _ANSI_RESET = "\033[0m" + +def _display_url(value: Any) -> str: + """Extract a display-only URL without assuming model argument types.""" + if isinstance(value, dict): + value = value.get("url") or value.get("href") + return value.strip() if isinstance(value, str) else "" + + # Diff colors — resolved lazily from the skin engine so they adapt # to light/dark themes. Falls back to sensible defaults on import # failure. We cache after first resolution for performance. @@ -1259,7 +1267,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return False, "" -def get_cute_tool_message( +def _get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: """Generate a formatted tool completion line for CLI quiet mode. @@ -1301,9 +1309,11 @@ def _wrap(line: str) -> str: if tool_name == "web_extract": urls = args.get("urls", []) if urls: - url = urls[0] if isinstance(urls, list) else str(urls) + url = _display_url(urls[0] if isinstance(urls, list) else urls) + if not url: + return _wrap(f"┊ 📄 fetch pages {dur}") domain = url.replace("https://", "").replace("http://", "").split("/")[0] - extra = f" +{len(urls)-1}" if len(urls) > 1 else "" + extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else "" return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") return _wrap(f"┊ 📄 fetch pages {dur}") if tool_name == "terminal": @@ -1433,6 +1443,19 @@ def _wrap(line: str) -> str: return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}") +def get_cute_tool_message( + tool_name: str, args: dict, duration: float, result: str | None = None, +) -> str: + """Render a completion label without letting cosmetic failures escape.""" + try: + return _get_cute_tool_message(tool_name, args, duration, result=result) + except Exception as exc: # noqa: BLE001 — display must never abort a turn + logger.debug("Tool completion label failed for %s: %s", tool_name, exc) + safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool" + safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done" + return f"┊ ⚡ {safe_name:9} completed {safe_duration}" + + # ========================================================================= # Honcho session line (one-liner with clickable OSC 8 hyperlink) # ========================================================================= diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 9700f4abe85d..cd7fa3ea4ea2 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -120,7 +120,7 @@ def __init__( def _slot_label(slot: dict[str, str]) -> str: - return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}" + return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}" def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index dbbcf81b72fb..c8b107ae310e 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1077,7 +1077,7 @@ def _load_context_cache() -> Dict[str, int]: try: with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - return data.get("context_lengths", {}) + return data.get("context_lengths") or {} except Exception as e: logger.debug("Failed to load context length cache: %s", e) return {} diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 5c9db408b1d8..91ad75cd121b 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -34,6 +34,7 @@ from agent.tool_result_classification import ( FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, ) +from tools.threat_patterns import scan_for_threats logger = logging.getLogger(__name__) @@ -379,13 +380,21 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict callers should compare by value, not by ``is``. """ wrapped = _maybe_wrap_untrusted(name, content) - return { + message = { "role": "tool", "name": name, "tool_name": name, "content": wrapped, "tool_call_id": tool_call_id, } + try: + risk_metadata = _tool_output_risk_metadata(name, content) + except Exception as exc: + logger.debug("Tool output risk scan failed for %s: %s", name, exc) + else: + if risk_metadata is not None: + message["_tool_output_risk"] = risk_metadata + return message # Tools whose results carry attacker-controllable content. Wrapping their @@ -419,6 +428,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool: return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES) +def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]: + """Classify textual attacker-controlled output without retaining a copy. + + The advisory metadata is internal-only. It records deterministic finding + identifiers, never blocks or redacts the normal result, and deliberately + omits raw scanned text. + """ + if not _is_untrusted_tool(name): + return None + if isinstance(content, str): + text_parts = [content] + elif isinstance(content, list): + text_parts = [ + item["text"] + for item in content + if isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ] + if not text_parts: + return None + else: + return None + + findings: List[str] = [] + for text in text_parts: + for finding in scan_for_threats(text, scope="context"): + if finding not in findings: + findings.append(finding) + return { + "risk": "high" if findings else "low", + "findings": findings, + "redacted": False, + } + + def _neutralize_delimiters(content: str) -> str: """Defang any literal ``untrusted_tool_result`` delimiter embedded in attacker-controlled content so it can't break out of the wrapper. diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 9688f5d3dc6a..e39263fe7584 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -74,6 +74,25 @@ def _budget_for_agent(agent) -> BudgetConfig: _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0 +def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]: + """Parse model-emitted arguments without repairing or coercing them.""" + try: + arguments = json.loads(raw_arguments) + except (json.JSONDecodeError, TypeError): + arguments = None + if isinstance(arguments, dict): + return arguments, None + return {}, json.dumps( + { + "error": "Invalid tool arguments", + "message": ( + "Tool arguments must be a valid JSON object; tool was not executed." + ), + }, + ensure_ascii=False, + ) + + def _resolve_concurrent_tool_timeout() -> float | None: raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip() if not raw: @@ -337,19 +356,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe for tool_call in tool_calls: function_name = tool_call.function.name - # Reset nudge counters + function_args, malformed_args_result = _parse_tool_arguments( + tool_call.function.arguments + ) + + if malformed_args_result is not None: + parsed_calls.append( + ( + tool_call, + function_name, + function_args, + [], + malformed_args_result, + False, + ) + ) + continue + + # Reset nudge counters only for a structurally valid invocation. if function_name == "memory": agent._turns_since_memory = 0 elif function_name == "skill_manage": agent._iters_since_skill = 0 - try: - function_args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError: - function_args = {} - if not isinstance(function_args, dict): - function_args = {} - # ── Tool Search unwrap ──────────────────────────────────────── # When the model invokes the tool_call bridge, peel it open so # every downstream check (checkpointing, guardrails, plugin @@ -935,7 +964,25 @@ def _run_tool(index, tool_call, function_name, function_args, middleware_trace): # image tool result never poisons canonical session history. # String results pass through unchanged. _tool_content = agent._tool_result_content_for_active_model(name, function_result) - messages.append(make_tool_result_message(name, _tool_content, tc.id)) + tool_message = make_tool_result_message(name, _tool_content, tc.id) + messages.append(tool_message) + risk_metadata = tool_message.get("_tool_output_risk") + if ( + risk_metadata is not None + and risk_metadata.get("risk") != "low" + and agent.tool_progress_callback + ): + try: + agent.tool_progress_callback( + "tool.output_risk", + name, + None, + None, + tool_call_id=tc.id, + risk_metadata=risk_metadata, + ) + except Exception as cb_err: + logging.debug("Tool output risk callback error: %s", cb_err) _flush_session_db_after_tool_progress( agent, messages, @@ -990,13 +1037,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe function_name = tool_call.function.name - try: - function_args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError as e: - logger.warning(f"Unexpected JSON error after validation: {e}") - function_args = {} - if not isinstance(function_args, dict): - function_args = {} + function_args, malformed_args_result = _parse_tool_arguments( + tool_call.function.arguments + ) + if malformed_args_result is not None: + messages.append( + make_tool_result_message( + function_name, + malformed_args_result, + tool_call.id, + ) + ) + _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"invalid tool arguments {function_name}", + ) + agent._apply_pending_steer_to_tool_results(messages, 1) + continue # Tool Search unwrap — see execute_tool_calls_concurrent for full # rationale, including the scope gate (the unwrap dispatches the @@ -1584,7 +1642,25 @@ def _execute(next_args: dict) -> Any: # Unwrap _multimodal dicts to an OpenAI-style content list # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) - messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id)) + tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id) + messages.append(tool_message) + risk_metadata = tool_message.get("_tool_output_risk") + if ( + risk_metadata is not None + and risk_metadata.get("risk") != "low" + and agent.tool_progress_callback + ): + try: + agent.tool_progress_callback( + "tool.output_risk", + function_name, + None, + None, + tool_call_id=tool_call.id, + risk_metadata=risk_metadata, + ) + except Exception as cb_err: + logging.debug("Tool output risk callback error: %s", cb_err) _flush_session_db_after_tool_progress( agent, messages, diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 5eaad31848c7..b41f0fdd506f 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -42,6 +42,7 @@ def finalize_turn( original_user_message, _should_review_memory, _turn_exit_reason, + _pending_verification_response=None, ): """Run the post-loop finalization and return the turn ``result`` dict. @@ -50,10 +51,35 @@ def finalize_turn( """ from agent.conversation_loop import logger - if final_response is None and ( + budget_exhausted = ( api_call_count >= agent.max_iterations or agent.iteration_budget.remaining <= 0 - ): + ) + budget_fallback_eligible = ( + budget_exhausted + and not interrupted + and not failed + and str(_turn_exit_reason) in {"unknown", "budget_exhausted"} + ) + continuation_budget_exhausted = ( + final_response is None + and bool(_pending_verification_response) + and budget_fallback_eligible + ) + + iteration_limit_fallback = False + preserved_verification_fallback = False + if continuation_budget_exhausted: + # A verification/continuation gate deliberately withheld a composed + # answer, then consumed the remaining budget before producing a newer + # one. Preserve that exact answer instead of replacing it with another + # fallible model call. The explicit pending value is the provenance + # guard: unrelated error/recovery exits can never enter this branch. + final_response = _pending_verification_response + _turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})" + iteration_limit_fallback = True + preserved_verification_fallback = True + elif final_response is None and budget_fallback_eligible: # Budget exhausted — ask the model for a summary via one extra # API call with tools stripped. _handle_max_iterations injects a # user message and makes a single toolless request. @@ -68,20 +94,18 @@ def finalize_turn( "— requesting summary..." ) final_response = agent._handle_max_iterations(messages, api_call_count) + iteration_limit_fallback = True + if iteration_limit_fallback: # If running as a kanban worker, signal the dispatcher that the # worker could not complete (rather than treating it as a - # protocol violation). The agent loop strips tools before calling - # _handle_max_iterations, so the model cannot call kanban_block - # itself — we must do it on its behalf. + # protocol violation). This applies whether the user-facing fallback + # came from the summary call or an explicitly pending continuation; + # both exhausted the task budget and must advance the failure circuit. # # We route through ``_record_task_failure(outcome="timed_out")`` - # rather than ``kanban_block`` so this counts toward the - # ``consecutive_failures`` counter and the dispatcher's - # ``failure_limit`` circuit breaker (#29747 gap 2). Without this, - # a task whose worker keeps exhausting its budget would block - # silently each run, get auto-promoted by the operator (or never - # surface), and re-block in an endless loop with no signal. + # rather than ``kanban_block`` so this counts toward the dispatcher's + # consecutive-failure circuit breaker (#29747 gap 2). _kanban_task = os.environ.get("HERMES_KANBAN_TASK") if _kanban_task: try: @@ -304,6 +328,7 @@ def finalize_turn( # truncated partial (the "The" case from #34452). _is_partial_fragment = ( not _is_empty_terminal + and not preserved_verification_fallback and not str(_turn_exit_reason).startswith("text_response") and len(_stripped) <= 24 and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"} diff --git a/apps/desktop/electron/bootstrap-runner.test.ts b/apps/desktop/electron/bootstrap-runner.test.ts index a61cec77f59e..7ba50c85e74f 100644 --- a/apps/desktop/electron/bootstrap-runner.test.ts +++ b/apps/desktop/electron/bootstrap-runner.test.ts @@ -4,7 +4,15 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { cachedScriptPath, installedAgentInstallScript, resolveInstallScript, runBootstrap } from './bootstrap-runner' +import { + buildPinArgs, + buildPosixPinArgs, + cachedScriptPath, + hasExistingGitCheckout, + installedAgentInstallScript, + resolveInstallScript, + runBootstrap +} from './bootstrap-runner' const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh' @@ -54,6 +62,58 @@ test('installedAgentInstallScript resolves the installer in the agent checkout', } }) +test('existing checkout detection requires git metadata', () => { + const home = mkTmpHome() + + try { + const activeRoot = path.join(home, 'hermes-agent') + assert.equal(hasExistingGitCheckout(activeRoot), false) + + fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true }) + assert.equal(hasExistingGitCheckout(activeRoot), true) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) + +test('fresh bootstrap args include the packaged commit pin', () => { + const installStamp = { commit: 'a'.repeat(40), branch: 'main' } + + assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main']) + assert.deepEqual( + buildPosixPinArgs({ + installStamp, + activeRoot: '/tmp/hermes-agent', + hermesHome: '/tmp/hermes' + }), + [ + '--dir', + '/tmp/hermes-agent', + '--hermes-home', + '/tmp/hermes', + '--branch', + 'main', + '--commit', + installStamp.commit + ] + ) +}) + +test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => { + const installStamp = { commit: 'a'.repeat(40), branch: 'main' } + + assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main']) + assert.deepEqual( + buildPosixPinArgs({ + installStamp, + activeRoot: '/tmp/hermes-agent', + hermesHome: '/tmp/hermes', + pinCommit: false + }), + ['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main'] + ) +}) + test('resolveInstallScript prefers a cached script without touching the network', async () => { const home = mkTmpHome() diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index 21a8cb4cd5db..41ddd597a379 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -107,6 +107,18 @@ function installedAgentInstallScript(hermesHome) { } } +function hasExistingGitCheckout(activeRoot) { + if (!activeRoot) { + return false + } + + try { + return fs.existsSync(path.join(activeRoot, '.git')) + } catch { + return false + } +} + function cachedScriptPath(hermesHome, commit) { return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`) } @@ -529,13 +541,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome // Manifest + stage dispatch // --------------------------------------------------------------------------- -// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp -// so the repository stage clones the exact SHA the .exe was tested with -// instead of falling back to install.ps1's default ($Branch = "main"). -function buildPinArgs(installStamp) { +// Build the installer branch/pin args from the install stamp. The commit pin +// is fresh-install only: once a managed checkout already exists, bootstrap is +// a repair/update path and must not let an old packaged app detach the checkout +// back to the commit baked into that app. +function buildPinArgs(installStamp, { pinCommit = true } = {}) { const args = [] - if (installStamp && installStamp.commit) { + if (pinCommit && installStamp && installStamp.commit) { args.push('-Commit', installStamp.commit) } @@ -546,26 +559,34 @@ function buildPinArgs(installStamp) { return args } -function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) { +function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = true }) { const args = ['--dir', activeRoot, '--hermes-home', hermesHome] if (installStamp && installStamp.branch) { args.push('--branch', installStamp.branch) } - if (installStamp && installStamp.commit) { + if (pinCommit && installStamp && installStamp.commit) { args.push('--commit', installStamp.commit) } return args } -async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) { +async function fetchManifest({ + scriptPath, + installerKind, + emit, + hermesHome, + activeRoot, + installStamp, + pinCommit +}) { const isPosix = installerKind === 'posix' const args = isPosix - ? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })] - : ['-Manifest', ...buildPinArgs(installStamp)] + ? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })] + : ['-Manifest', ...buildPinArgs(installStamp, { pinCommit })] const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, { emit, @@ -622,7 +643,17 @@ function parseStageResult(stdout) { return null } -async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) { +async function runStage({ + scriptPath, + installerKind, + stage, + emit, + hermesHome, + activeRoot, + abortSignal, + installStamp, + pinCommit +}) { const startedAt = Date.now() emit({ type: 'stage', name: stage.name, state: 'running' }) @@ -634,9 +665,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac stage.name, '--non-interactive', '--json', - ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) + ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit }) ] - : ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)] + : ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp, { pinCommit })] const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, { emit, @@ -774,6 +805,18 @@ async function runBootstrap(opts) { }) try { + const existingCheckout = hasExistingGitCheckout(activeRoot) + const pinCommit = !existingCheckout + + if (existingCheckout && installStamp && installStamp.commit) { + emit({ + type: 'log', + line: + `[bootstrap] existing checkout detected at ${activeRoot}; ` + + `not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}` + }) + } + // 1. Resolve the platform installer. const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) const installerKind = scriptInfo.kind || 'powershell' @@ -785,7 +828,8 @@ async function runBootstrap(opts) { emit, hermesHome, activeRoot, - installStamp + installStamp, + pinCommit }) emit({ @@ -813,7 +857,8 @@ async function runBootstrap(opts) { hermesHome, activeRoot, abortSignal, - installStamp + installStamp, + pinCommit }) if (ev.state === 'failed') { @@ -847,7 +892,10 @@ async function runBootstrap(opts) { } export { + buildPinArgs, + buildPosixPinArgs, cachedScriptPath, + hasExistingGitCheckout, installedAgentInstallScript, // Exposed for testability parseStageResult, diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 34492bbd53fe..518d49805df8 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -20,7 +20,9 @@ import { buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, + cookiesHavePrivySession, cookiesHaveSession, + modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, @@ -47,6 +49,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => { assert.equal(normAuthMode('weird'), 'token') }) +// --- modeIsRemoteLike --- + +test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => { + // cloud resolves to a remote backend under the hood (Q6), so every resolution + // site treats it like remote. + assert.equal(modeIsRemoteLike('remote'), true) + assert.equal(modeIsRemoteLike('cloud'), true) + assert.equal(modeIsRemoteLike('local'), false) + assert.equal(modeIsRemoteLike(undefined), false) + assert.equal(modeIsRemoteLike(null), false) + assert.equal(modeIsRemoteLike('weird'), false) +}) + // --- profileRemoteOverride --- test('profileRemoteOverride returns null when no profile is given', () => { @@ -86,6 +101,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => { assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth') }) +test('profileRemoteOverride treats a cloud entry as a remote override', () => { + // A 'cloud' per-profile entry resolves to the same remote backend a 'remote' + // entry would (Q6) — the override must be returned, not dropped. + const config = { + profiles: { + coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' } + } + } + assert.deepEqual(profileRemoteOverride(config, 'coder'), { + url: 'https://agent-1.agents.nousresearch.com', + authMode: 'oauth', + token: undefined + }) +}) + test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride({}, 'coder'), null) assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null) @@ -332,6 +362,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () assert.equal(cookiesHaveLiveSession([]), false) }) +// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) --- + +test('cookiesHavePrivySession detects the privy-token access cookie', () => { + assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true) +}) + +test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => { + assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true) + assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true) + assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true) +}) + +test('cookiesHavePrivySession is false for an empty value', () => { + assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false) +}) + +test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => { + // The whole point of Q7: a gateway session cookie is NOT a portal sign-in. + assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false) + assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false) +}) + +test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => { + assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false) + assert.equal(cookiesHavePrivySession(null), false) + assert.equal(cookiesHavePrivySession(undefined), false) + assert.equal(cookiesHavePrivySession([]), false) +}) + // --- tokenPreview --- test('tokenPreview returns null for empty', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 5d84972dfc1a..569f9cc07261 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -37,6 +37,15 @@ const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'] const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'] +// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a +// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the +// `privy-token` access-token cookie (with `privy-id-token` alongside), which is +// also exactly what the `/api/agents` cookie-auth path validates. So portal +// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway +// cookies above. `privy-token` is the access token (the required signal); +// variants cover the secured-prefix forms and the older `privy-session` name. +const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session'] + function normalizeRemoteBaseUrl(rawUrl) { const value = String(rawUrl || '').trim() @@ -150,20 +159,31 @@ function normAuthMode(mode) { return mode === 'oauth' ? 'oauth' : 'token' } +// True for connection modes that resolve to a REMOTE backend. 'cloud' is a +// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a +// remote-shaped block and reuses the entire remote connect/probe/reconnect +// path, so every resolution site treats it exactly like 'remote'. The only +// places that distinguish cloud from remote are the settings UI (which card to +// show) and config persistence (remembering the provenance). Centralized here +// so no resolution site forgets the third arm. +function modeIsRemoteLike(mode) { + return mode === 'remote' || mode === 'cloud' +} + /** * Select a profile's explicit remote override from a connection config, or null * when it has none (so the caller falls back to env → global remote → local). * * The config may carry a `profiles` map keyed by name; an entry counts as an - * override only with `mode === 'remote'` and a non-empty `url`. Pure: `token` - * is the raw stored secret; main.ts decrypts it. Returns + * override only with a remote-like `mode` (remote or cloud) and a non-empty + * `url`. Pure: `token` is the raw stored secret; main.ts decrypts it. Returns * `{ url, authMode, token } | null`. */ function profileRemoteOverride(config, profile) { const key = connectionScopeKey(profile) const entry = key ? config?.profiles?.[key] : null - if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') { + if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) { return null } @@ -292,6 +312,22 @@ function cookiesHaveLiveSession(cookies) { return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))) } +/** + * True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty + * `privy-token` (access-token) cookie, or a variant. This is the portal + * analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not + * the Hermes gateway session cookies, so cloud sign-in / discovery liveness + * must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents` + * cookie path both key off `privy-token`.) + */ +function cookiesHavePrivySession(cookies) { + if (!Array.isArray(cookies)) { + return false + } + + return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name)) +} + export { AT_COOKIE_VARIANTS, authModeFromStatus, @@ -299,10 +335,13 @@ export { buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, + cookiesHavePrivySession, cookiesHaveSession, + modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, + PRIVY_SESSION_COOKIE_VARIANTS, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b8c2d03da380..ba50d6d7cc1b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -42,7 +42,9 @@ import { buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, + cookiesHavePrivySession, cookiesHaveSession, + modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, @@ -800,6 +802,9 @@ function registerMediaProtocol() { let mainWindow = null let hermesProcess = null let connectionPromise = null +// True while connection-config:apply soft-rehomes the primary — suppresses the +// backend-exit toast so an intentional kill doesn't look like a crash. +let softRehomeInProgress = false // Additional per-profile backends, keyed by profile name. The PRIMARY backend // (the desktop's launch profile) stays managed by hermesProcess + // connectionPromise + startHermes(); this pool only holds EXTRA profile @@ -4489,6 +4494,12 @@ function getWindowState() { } function sendBackendExit(payload) { + // Intentional soft re-home (gateway mode apply) kills the child on purpose — + // don't surface the "backend stopped" error toast / boot-failure path. + if (softRehomeInProgress) { + return + } + if (!mainWindow || mainWindow.isDestroyed()) { return } @@ -4743,7 +4754,13 @@ function installPreviewShortcut(window) { // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. -import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' +import { + clampZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_STORAGE_KEY, + zoomLevelToPercent +} from './zoom' function setAndPersistZoomLevel(window, zoomLevel) { if (!window || window.isDestroyed()) { @@ -5088,12 +5105,25 @@ async function clearOauthSession(baseUrl) { } } -// Open the gateway's /login page in a visible window using the OAuth session -// partition, and resolve once the access-token cookie appears (login done) or -// reject if the user closes the window first. The window navigates through the -// IDP and back to /auth/callback, which sets the session cookies on the -// partition; we poll the cookie jar rather than try to read the HttpOnly value. -function openOauthLoginWindow(baseUrl) { +// Open a gateway login window in the OAuth session partition, resolving once +// the access-token cookie appears (login done) or rejecting if the user closes +// the window first. The window navigates through the IDP and back to +// /auth/callback, which sets the session cookies on the partition; we poll the +// cookie jar rather than try to read the HttpOnly value. +// +// `silent` selects the URL the window loads, which decides interactive-vs-silent: +// - silent=false (default): load ``/login`` — the public interstitial that +// renders the "Log in with X" provider chooser. This is the interactive +// remote-gateway login the settings UI drives. +// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public +// route, so loading it NEVER triggers the gate's auto-SSO and always shows +// the chooser. Loading a protected page with no session cookie makes the +// gate run ``_auto_sso_response``: single registered provider + a live +// portal session in this partition → a silent 302 through +// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members) +// → ``/auth/callback``, which sets the gateway cookie with NO interactive +// prompt. This is the per-agent cloud cascade (decisions.md Q5). +function openOauthLoginWindow(baseUrl, { silent = false } = {}) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) @@ -5112,6 +5142,7 @@ function openOauthLoginWindow(baseUrl) { let settled = false let win = null let pollTimer = null + let revealTimer = null const finish = err => { if (settled) { @@ -5123,6 +5154,10 @@ function openOauthLoginWindow(baseUrl) { clearInterval(pollTimer) } + if (revealTimer) { + clearTimeout(revealTimer) + } + try { if (win && !win.isDestroyed()) { win.destroy() @@ -5152,8 +5187,14 @@ function openOauthLoginWindow(baseUrl) { win = new BrowserWindow({ width: 520, height: 720, - title: 'Sign in to Hermes gateway', + title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', autoHideMenuBar: true, + // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in + // well under a second, so the window normally never needs to show. We + // only reveal it as a fallback if the cascade DOESN'T complete quickly + // (e.g. the portal session lapsed and the gate fell through to the + // interactive chooser) — see the reveal timer below. + show: !silent, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -5176,6 +5217,23 @@ function openOauthLoginWindow(baseUrl) { win.webContents.on('did-frame-navigate', () => void checkCookie()) pollTimer = setInterval(() => void checkCookie(), 750) + // Silent-mode reveal fallback: if the cascade hasn't settled shortly, the + // auto-SSO didn't go through silently (no portal session, multi-provider, + // loop-guard tripped, etc.) and the window is now showing an interactive + // page. Reveal it so the user can complete sign-in manually rather than + // staring at nothing. Cleared on finish(). + if (silent && win) { + revealTimer = setTimeout(() => { + try { + if (!settled && win && !win.isDestroyed() && !win.isVisible()) { + win.show() + } + } catch { + // window torn down + } + }, 2500) + } + win.on('closed', () => { if (!settled) { finish(new Error('Login window closed before authentication completed.')) @@ -5185,7 +5243,11 @@ function openOauthLoginWindow(baseUrl) { // ``next`` is intentionally omitted: the gateway lands on ``/`` after // login, which is a valid authenticated page that sets the cookies. We // only care that the cookie jar is populated. - const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login` + // + // silent=true loads the protected root so the gate auto-SSOs (no chooser); + // silent=false loads the public ``/login`` chooser for interactive sign-in. + const normalizedBase = normalizeRemoteBaseUrl(baseUrl) + const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login` win.loadURL(loginUrl).catch(error => { finish(error instanceof Error ? error : new Error(String(error))) }) @@ -5347,6 +5409,310 @@ async function freshGatewayWsUrl(profile) { return connection.wsUrl } +// --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery +// Phase 3) --------------------------------------------------------------- +// +// The "cloud" connection mode lets a user sign in to the Nous portal ONCE in +// the OAuth session partition, then (a) discover their hosted agents and (b) +// connect to any of them with no second interactive sign-in. Both ride the one +// portal session cookie living in `persist:hermes-remote-oauth`: +// - discovery → GET {portal}/api/agents over the partition-bound net; the +// portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie). +// - cascade → opening an agent's own /login in the same partition hits the +// portal's silent auto-approve (org member, existing session) and 302s back +// with that agent's session cookie — no prompt. Each agent still completes +// its own PKCE exchange; SSO removes the human click, not a security check. + +// Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI +// convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names) +// so a single override flips every Hermes surface to the same portal. +const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com' + +function resolvePortalBaseUrl() { + const raw = process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL + + return String(raw).trim().replace(/\/+$/, '') +} + +// Whether the OAuth partition currently holds a live Nous portal session — the +// credential that powers both discovery and the silent cascade. The portal +// authenticates via PRIVY, not the Hermes gateway session cookies, so this +// checks for the `privy-token` cookie on the portal host (NOT +// hasLiveOauthSession, which looks for hermes_session_at/rt that the portal +// never sets). See connection-config.ts cookiesHavePrivySession. +async function hasLivePortalSession() { + const sess = getOauthSession() + + if (!sess) { + return false + } + + const portalBaseUrl = resolvePortalBaseUrl() + const parsed = new URL(portalBaseUrl) + + try { + const cookies = await sess.cookies.get({ url: portalBaseUrl }) + + return cookiesHavePrivySession(cookies) + } catch { + try { + const cookies = await sess.cookies.get({ domain: parsed.hostname }) + + return cookiesHavePrivySession(cookies) + } catch { + return false + } + } +} + +// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike +// openOauthLoginWindow (which targets a gateway's /login), this lands on the +// portal itself so the resulting session cookie is portal-scoped — the cookie +// that authenticates discovery AND is reused for every silent per-agent +// cascade. Resolves once the portal session cookie appears. +function openPortalLoginWindow() { + const portalBaseUrl = resolvePortalBaseUrl() + + return new Promise((resolve, reject) => { + if (!app.isReady()) { + reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.')) + + return + } + + const sess = getOauthSession() + + if (!sess) { + reject(new Error('OAuth session partition is unavailable.')) + + return + } + + let settled = false + let win = null + let pollTimer = null + + const finish = err => { + if (settled) { + return + } + settled = true + + if (pollTimer) { + clearInterval(pollTimer) + } + + try { + if (win && !win.isDestroyed()) { + win.destroy() + } + } catch { + // window already torn down + } + + if (err) { + reject(err) + } else { + resolve({ portalBaseUrl, ok: true }) + } + } + + const checkCookie = async () => { + if (settled) { + return + } + + // A live portal (Privy) session cookie means sign-in completed. + if (await hasLivePortalSession()) { + finish(null) + } + } + + try { + win = new BrowserWindow({ + width: 520, + height: 720, + title: 'Sign in to Hermes Cloud', + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + session: sess, + webSecurity: true + } + }) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + + return + } + + win.webContents.on('did-navigate', () => void checkCookie()) + win.webContents.on('did-redirect-navigation', () => void checkCookie()) + win.webContents.on('did-frame-navigate', () => void checkCookie()) + pollTimer = setInterval(() => void checkCookie(), 750) + + win.on('closed', () => { + if (!settled) { + finish(new Error('Sign-in window closed before authentication completed.')) + } + }) + + // Land on the portal root; any authenticated portal page sets the session + // cookie. We only care that the partition cookie jar is populated. + win.loadURL(portalBaseUrl).catch(error => { + finish(error instanceof Error ? error : new Error(String(error))) + }) + }) +} + +// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls +// the NAS trimmed-summary endpoint over the partition-bound net, so the portal +// session cookie is attached automatically (no bearer needed — NAS accepts the +// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs } +// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409 +// org_selection_required). Pass `org` (a slug/id from a prior org list) to +// scope discovery to that org. Throws a needsCloudLogin-tagged error when no +// portal session is present. +async function discoverCloudAgents(org?: string) { + const portalBaseUrl = resolvePortalBaseUrl() + + if (!(await hasLivePortalSession())) { + const err = new Error( + 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' + ) as any + err.needsCloudLogin = true + throw err + } + + const orgQuery = org ? `?org=${encodeURIComponent(org)}` : '' + let body + + try { + body = (await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, { + method: 'GET', + timeoutMs: 15_000 + })) as any + } catch (error) { + // A 401 means the portal session lapsed between the liveness check and the + // call — surface it as a re-login, not a generic failure. + if (error && error.statusCode === 401) { + const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.') as any + err.needsCloudLogin = true + err.cause = error + throw err + } + + // A 409 means we're a multi-org user who hasn't picked an org. The body + // carries the user's org list; surface it so the renderer shows a picker + // and re-calls discovery with the chosen org. (fetchJsonViaOauthSession + // throws on >=400 with err.statusCode + err.message "409: ".) + if (error && error.statusCode === 409) { + const orgs = parseOrgSelectionError(error) + + if (orgs) { + return { needsOrgSelection: true, orgs } + } + } + + throw error + } + + return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) } +} + +// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed +// shape the renderer persists, or null when absent/malformed. +function trimCloudOrg(org) { + if (!org || typeof org !== 'object' || typeof org.id !== 'string') { + return null + } + + return { + id: org.id, + slug: typeof org.slug === 'string' ? org.slug : null, + name: typeof org.name === 'string' ? org.name : org.id, + isPersonal: Boolean(org.isPersonal), + role: typeof org.role === 'string' ? org.role : 'MEMBER' + } +} + +// Extract the org list from a 409 org_selection_required error body. The error +// message is "409: " (see fetchJsonViaOauthSession); parse defensively +// and return null if it isn't the shape we expect (caller then rethrows). +function parseOrgSelectionError(error) { + const msg = String(error?.message || '') + const jsonStart = msg.indexOf('{') + + if (jsonStart < 0) { + return null + } + + let parsed + + try { + parsed = JSON.parse(msg.slice(jsonStart)) + } catch { + return null + } + + if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) { + return null + } + + return parsed.orgs + .filter(o => o && typeof o === 'object' && typeof o.id === 'string') + .map(o => ({ + id: o.id, + slug: typeof o.slug === 'string' ? o.slug : null, + name: typeof o.name === 'string' ? o.name : o.id, + isPersonal: Boolean(o.isPersonal), + role: typeof o.role === 'string' ? o.role : 'MEMBER' + })) +} + +// Project NAS's agent rows to the trimmed DTO the renderer consumes. +function trimCloudAgents(body) { + const agents = Array.isArray(body?.agents) ? body.agents : [] + + return agents + .filter(a => a && typeof a === 'object' && typeof a.id === 'string') + .map(a => ({ + id: a.id, + name: typeof a.name === 'string' ? a.name : a.id, + status: typeof a.status === 'string' ? a.status : 'unknown', + dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null, + dashboardGatewayState: typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown' + })) +} + +// Silent per-agent sign-in: open the selected agent dashboard's /login in the +// SAME OAuth partition. Because the user already holds a live portal session +// there, the agent's /oauth/authorize auto-approves (org member) and 302s back, +// setting that agent's gateway session cookie WITHOUT a second interactive +// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the +// agent's session cookie lands (a silent flow finishes in well under a second; +// if the portal session were absent it would fall through to an interactive +// login, which the discovery gate already prevents). Returns once the agent's +// gateway session cookie is present. +async function cloudAgentSilentSignIn(dashboardUrl) { + const baseUrl = normalizeRemoteBaseUrl(dashboardUrl) + + // Pre-req: a live portal session must exist, or this would surface an + // interactive prompt rather than a silent cascade. Discovery already gates on + // this, but a selection can arrive after the session lapsed. + if (!(await hasLivePortalSession())) { + const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.') as any + err.needsCloudLogin = true + throw err + } + + await openOauthLoginWindow(baseUrl, { silent: true }) + + return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } +} + function encryptDesktopSecret(value) { return encryptDesktopSecretStrict(value, safeStorage) } @@ -5392,8 +5758,14 @@ function sanitizeConnectionProfiles(raw: Record) { continue } - const cleaned: { mode: 'remote' | 'local'; url?: string; authMode?: string; token?: object } = { - mode: entry.mode === 'remote' ? 'remote' : 'local' + const cleaned: { + mode: 'remote' | 'local' | 'cloud' + url?: string + authMode?: string + token?: object + org?: string + } = { + mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } const url = String(entry.url || '').trim() @@ -5407,6 +5779,16 @@ function sanitizeConnectionProfiles(raw: Record) { cleaned.token = entry.token } + // Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can + // reopen into the same org for a per-profile cloud connection. + if (cleaned.mode === 'cloud') { + const org = String(entry.org || '').trim() + + if (org) { + cleaned.org = org + } + } + out[name] = cleaned } @@ -5442,7 +5824,7 @@ function readDesktopConnectionConfig() { // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { - mode: parsed.mode === 'remote' ? 'remote' : 'local', + mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so @@ -5513,7 +5895,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') - const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' + // The env override forces a plain remote connection. Otherwise reflect the + // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI + // reopens into the cloud picker; any non-remote-like value collapses to local. + const savedMode = key ? scoped?.mode : config.mode + const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' let remoteOauthConnected = false @@ -5536,6 +5922,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon remoteAuthMode: authMode, remoteOauthConnected, remoteUrl, + // The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for + // remote/local. Lets Settings → Gateway reopen into the same org. + cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), // The env override only forces the global/primary connection; a per-profile @@ -5547,24 +5936,55 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // Build + validate a `{ url, authMode, token }` remote block. OAuth gateways // authenticate via the login-window session cookie (verified at connect time in // resolveRemoteBackend), so only token-auth remotes require a saved token. -function buildRemoteBlock(remoteUrl, authMode, token) { +// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered +// under — persisted so Settings can reopen into the same org; omitted from the +// block when empty so plain remote connections stay unchanged. +function buildRemoteBlock(remoteUrl, authMode, token, org?: string) { if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { throw new Error('Remote gateway session token is required.') } - return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } + const block: { url: string; authMode: string; token: object; org?: string } = { + url: normalizeRemoteBaseUrl(remoteUrl), + authMode, + token + } + const orgValue = typeof org === 'string' ? org.trim() : '' + + if (orgValue) { + block.org = orgValue + } + + return block } function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) - const mode = input.mode === 'remote' ? 'remote' : 'local' + // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is + // remembered as its own provenance (Q6) and resolves to remote downstream. + // Anything else collapses to local. + const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local' + const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. - const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + // Leaving a CLOUD connection unselects it: a cloud block's url/org/token + // describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway, + // so switching to local or remote must NOT inherit them (otherwise the stale + // cloud URL lingers and re-selecting Cloud looks "already connected"). When the + // saved block was cloud and the new mode is not cloud, start from an empty + // block. (remote↔local toggles still preserve a real remote URL as before.) + const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode + const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' + const existingBlock = leavingCloud ? {} : rawExistingBlock const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) + // Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise + // inherit the saved org. A plain 'remote' connection never carries an org + // (switching cloud→remote drops it), so it stays unset unless mode is cloud. + const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : '' const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' const nextToken = incomingToken @@ -5574,23 +5994,27 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo : existingBlock.token if (key) { - // Per-profile scope: a remote entry pins this profile to its own backend; a - // local entry clears the override so the profile inherits the default. + // Per-profile scope: a remote/cloud entry pins this profile to its own + // backend; a local entry clears the override so the profile inherits the + // default. The mode tag (remote vs cloud) is preserved on the entry. const profiles = { ...(existing.profiles || {}) } - if (mode === 'remote') { - profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } + if (remoteLike) { + profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { delete profiles[key] } - return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } + return { + mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + remote: existing.remote || {}, + profiles + } } - const nextRemote = - mode === 'remote' - ? buildRemoteBlock(remoteUrl, authMode, nextToken) - : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + const nextRemote = remoteLike + ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } @@ -5701,8 +6125,8 @@ async function resolveRemoteBackend(profile) { return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } - // 3. Global remote. - if (config.mode !== 'remote') { + // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6). + if (!modeIsRemoteLike(config.mode)) { return null } @@ -5727,14 +6151,15 @@ function configuredRemoteProfileNames() { } // True when the app is in app-global remote mode (Settings → "All profiles" → -// Remote, or the env override): a SINGLE remote backend serves every profile via -// ?profile=. Distinct from per-profile overrides — here there's one host for all. +// Remote/Cloud, or the env override): a SINGLE remote backend serves every +// profile via ?profile=. Cloud counts — it resolves to a remote backend (Q6). +// Distinct from per-profile overrides — here there's one host for all. function globalRemoteActive() { if (process.env.HERMES_DESKTOP_REMOTE_URL) { return true } - return readDesktopConnectionConfig().mode === 'remote' + return modeIsRemoteLike(readDesktopConnectionConfig().mode) } // GET a profile's resolved backend (remote pool or local primary), parsed JSON. @@ -5826,7 +6251,7 @@ async function testDesktopConnectionConfig(input: any = {}) { const block = key ? config.profiles?.[key] || null : config.remote const wantRemote = - block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + modeIsRemoteLike(block?.mode) || (!key && modeIsRemoteLike(config.mode)) || (modeIsRemoteLike(input.mode) && block) // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only @@ -5911,26 +6336,57 @@ function stopBackendChild(child) { } } -function resetHermesConnection() { +// Soft gateway-mode apply: tear down the primary without resetting boot UI or +// reloading the renderer. The shell stays up; the renderer wipes session lists +// (so skeletons retrigger) and re-dials. Distinct from hard re-home (profile +// switch / crash recovery), which still resets boot progress + reloads. +function resetHermesConnection({ soft = false } = {}) { connectionPromise = null backendStartFailure = null stopBackendChild(hermesProcess) hermesProcess = null - resetBootProgressForReconnect() + + if (!soft) { + resetBootProgressForReconnect() + } } // Re-home the primary backend: reset connection state, then wait for the live // dashboard process to actually exit (SIGKILL after 5s) so the next // startHermes() spawns fresh instead of racing the dying one. Shared by the // connection-config and profile switch flows. -async function teardownPrimaryBackendAndWait() { +async function teardownPrimaryBackendAndWait({ soft = false } = {}) { // Capture the reference before resetHermesConnection() nulls hermesProcess. const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null - resetHermesConnection() - await waitForBackendExit(dying) + if (soft) { + softRehomeInProgress = true + } + + try { + resetHermesConnection({ soft }) + await waitForBackendExit(dying) + } finally { + if (soft) { + softRehomeInProgress = false + } + } +} + +function sendConnectionApplied() { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + const { webContents } = mainWindow + + if (!webContents || webContents.isDestroyed()) { + return + } + + webContents.send('hermes:connection:applied') } async function waitForBackendExit(child, timeoutMs = 5000) { @@ -6507,10 +6963,21 @@ async function startHermes() { // security posture: external links open in the OS browser, in-app navigation // stays confined to the dev server / packaged file URL, and the preview / // devtools / zoom / context-menu affordances behave identically everywhere. -function wireCommonWindowHandlers(win) { +// +// `zoom` is opt-out for the pet overlay: it sizes its own OS window to fit the +// sprite in unzoomed CSS px (overlayWindowSize -> setBounds) and has its own +// Alt+wheel scale, so inheriting the global UI zoom would render the mascot +// larger than its window and crop it. Chat windows keep zoom on. +function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) { installPreviewShortcut(win) installDevToolsShortcut(win) - installZoomShortcuts(win) + if (zoom) { + installZoomShortcuts(win) + // Re-apply persisted zoom on show/restore (Windows drops webContents zoom on + // minimize/restore) and on first load (reloads / crash recovery). + installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win)) + win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win)) + } installContextMenu(win) win.webContents.setWindowOpenHandler(details => { openExternalUrl(details.url) @@ -6702,7 +7169,9 @@ function spawnPetOverlayWindow(bounds) { // Not supported everywhere — best effort. } - wireCommonWindowHandlers(win) + // Pet overlay opts out of global UI zoom (see wireCommonWindowHandlers): it + // owns its window-fit + scale, and inheriting zoom would crop the sprite. + wireCommonWindowHandlers(win, { zoom: false }) win.once('ready-to-show', () => { if (!win.isDestroyed()) { @@ -6893,7 +7362,8 @@ function createWindow() { } mainWindow.webContents.once('did-finish-load', () => { - restorePersistedZoomLevel(mainWindow) + // Zoom restore is handled by wireCommonWindowHandlers (shared with session + // windows); no need to reapply it here. broadcastBootProgress() sendWindowStateChanged() startHermes().catch(error => rememberLog(error.stack || error.message)) @@ -7190,6 +7660,35 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) = // as still-connected rather than silently signed-out. return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false } }) + +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- +// One portal login in the OAuth partition powers both discovery and the silent +// per-agent cascade. See the discovery/cascade helpers above. +ipcMain.handle('hermes:cloud:status', async () => ({ + portalBaseUrl: resolvePortalBaseUrl(), + signedIn: await hasLivePortalSession() +})) +ipcMain.handle('hermes:cloud:login', async () => { + await openPortalLoginWindow() + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:logout', async () => { + await clearOauthSession(resolvePortalBaseUrl()) + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:discover', async (_event, org) => { + // Returns { agents } or { needsOrgSelection: true, orgs }. `org` (optional) + // scopes discovery to a chosen org for multi-org users. + return discoverCloudAgents(typeof org === 'string' && org ? org : undefined) +}) +ipcMain.handle('hermes:cloud:agent-sign-in', async (_event, dashboardUrl) => { + // Silent per-agent sign-in via the shared portal session. Returns the agent's + // gateway baseUrl + whether its session cookie landed; the renderer then + // saves a cloud-mode connection pointed at this dashboardUrl. + return cloudAgentSilentSignIn(dashboardUrl) +}) ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) @@ -7208,10 +7707,11 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { // re-resolves against the new remote/local target. stopPoolBackend(key) } else { - // Global connection, or the primary profile's connection: re-home the - // window backend by tearing it down and reloading the renderer. - await teardownPrimaryBackendAndWait() - mainWindow?.reload() + // Global / primary connection: soft re-home. Tear down the window backend + // without resetting boot UI or reloading — the shell stays, the renderer + // wipes session lists (skeletons) and re-dials on hermes:connection:applied. + await teardownPrimaryBackendAndWait({ soft: true }) + sendConnectionApplied() } return sanitizeDesktopConnectionConfig(config, payload?.profile) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 3a74b1c3ebc2..8b6a14b0cf7c 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -43,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + // Hermes Cloud: one portal login powers discovery + silent per-agent sign-in + // (cloud-auto-discovery Phase 3). + cloud: { + status: () => ipcRenderer.invoke('hermes:cloud:status'), + login: () => ipcRenderer.invoke('hermes:cloud:login'), + logout: () => ipcRenderer.invoke('hermes:cloud:logout'), + discover: org => ipcRenderer.invoke('hermes:cloud:discover', org), + agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl) + }, profile: { get: () => ipcRenderer.invoke('hermes:profile:get'), set: name => ipcRenderer.invoke('hermes:profile:set', name) @@ -195,6 +204,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:backend-exit', listener) }, + // Soft gateway-mode apply finished tearing down the primary backend. Renderer + // should wipe session lists + re-dial without a window reload. + onConnectionApplied: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:connection:applied', listener) + + return () => ipcRenderer.removeListener('hermes:connection:applied', listener) + }, onPowerResume: callback => { const listener = () => callback() ipcRenderer.on('hermes:power-resume', listener) diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index 0636c8c52e2a..8be69e813a39 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -5,9 +5,19 @@ */ import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' import test from 'node:test' +import { fileURLToPath } from 'node:url' -import { clampZoomLevel, percentToZoomLevel, ZOOM_STORAGE_KEY, zoomLevelToPercent } from './zoom' +import { + clampZoomLevel, + installZoomReassertOnWindowEvents, + percentToZoomLevel, + ZOOM_REASSERT_WINDOW_EVENTS, + ZOOM_STORAGE_KEY, + zoomLevelToPercent +} from './zoom' test('storage key stays stable so persisted zoom survives upgrades', () => { assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel') @@ -53,3 +63,63 @@ test('extreme percentages clamp to the level bounds', () => { assert.equal(percentToZoomLevel(1), -9) assert.equal(percentToZoomLevel(1_000_000), 9) }) + +test('installZoomReassertOnWindowEvents wires show and restore', () => { + const handlers = new Map() + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + + assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS]) + handlers.get('show')() + handlers.get('restore')() + assert.equal(calls, 2) +}) + +test('installZoomReassertOnWindowEvents skips destroyed windows', () => { + const handlers = new Map() + let destroyed = false + const win = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + let calls = 0 + installZoomReassertOnWindowEvents(win, () => { + calls += 1 + }) + destroyed = true + handlers.get('show')() + assert.equal(calls, 0) +}) + +// Source assertion (see windows-child-process.test.ts for the established +// pattern): wireCommonWindowHandlers lives in the electron main entry with heavy +// Electron deps, so we assert the wiring contract against source rather than +// booting a BrowserWindow. Locks in that the pet overlay opts OUT of global UI +// zoom while chat windows keep it — the whole reason this fix is scoped. +test('pet overlay opts out of global UI zoom; chat windows keep it', () => { + const electronDir = path.dirname(fileURLToPath(import.meta.url)) + const source = fs.readFileSync(path.join(electronDir, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') + + // The shared helper gates all zoom wiring behind an opt-out flag. + assert.match(source, /function wireCommonWindowHandlers\(win, \{ zoom = true \}/) + + // The pet overlay window is the only caller that disables zoom. + assert.match(source, /wireCommonWindowHandlers\(win, \{ zoom: false \}\)/) + + // Zoom restore now flows through the shared helper, so createWindow must not + // reassert it directly (that would double-fire and drift from session windows). + const finishLoad = source.indexOf("mainWindow.webContents.once('did-finish-load'") + assert.notEqual(finishLoad, -1, 'missing mainWindow did-finish-load handler') + const snippet = source.slice(finishLoad, finishLoad + 300) + assert.doesNotMatch(snippet, /restorePersistedZoomLevel\(mainWindow\)/) +}) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index f3518f583d62..31182ea64d9b 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -31,3 +31,22 @@ export function percentToZoomLevel(percent) { return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) } + +// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized +// and restored. Re-apply the persisted level on these lifecycle transitions. +export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore'] + +export function installZoomReassertOnWindowEvents(win, reassert) { + if (!win?.on) { + return + } + + for (const event of ZOOM_REASSERT_WINDOW_EVENTS) { + win.on(event, () => { + if (win.isDestroyed?.()) { + return + } + reassert() + }) + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fe91d2991c09..d7c099c140a7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", + "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", "postbuild": "node scripts/assert-dist-built.mjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs", @@ -38,7 +38,7 @@ "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", - "typecheck": "tsc -p . --noEmit", + "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index eb893c3675a0..2672e95a676e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -97,6 +97,7 @@ function fakeDesktop() { })), onBootProgress: vi.fn(() => () => undefined), onBackendExit: vi.fn(() => () => undefined), + onConnectionApplied: vi.fn(() => () => undefined), onPowerResume: vi.fn(() => () => undefined), onWindowStateChanged: vi.fn(() => () => undefined), touchBackend: vi.fn(async () => undefined), diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 26a4e2ce7c8e..f1b040bec1e5 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -23,6 +23,7 @@ import { setPrimaryGateway, touchSecondaryGateways } from '@/store/gateway' +import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from '@/store/gateway-switch' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile' import { @@ -130,7 +131,7 @@ export function useGatewayBoot({ } const attemptReconnect = async () => { - if (cancelled || reconnecting || gatewayOpen()) { + if (cancelled || reconnecting || gatewayOpen() || $gatewaySwitching.get()) { return } @@ -181,7 +182,7 @@ export function useGatewayBoot({ } finally { reconnecting = false - if (!cancelled && !gatewayOpen()) { + if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) { if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) { escalated = true failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost')) @@ -193,7 +194,7 @@ export function useGatewayBoot({ } function scheduleReconnect() { - if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) { + if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen() || $gatewaySwitching.get()) { return } @@ -207,7 +208,7 @@ export function useGatewayBoot({ } const reconnectNow = () => { - if (cancelled || !bootCompleted) { + if (cancelled || !bootCompleted || $gatewaySwitching.get()) { return } @@ -221,7 +222,97 @@ export function useGatewayBoot({ } } - const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload)) + // Adopt the profile the primary (window) backend booted as, so same-profile + // resumes are no-op swaps and reconnects target the right backend. + // Best-effort: a missing preference means "default". Shared by boot + soft + // switch. + async function adoptPrimaryProfile() { + try { + const pref = await desktop.profile?.get?.() + const profileKey = (pref?.profile ?? '').trim() || 'default' + $activeGatewayProfile.set(profileKey) + setPrimaryGateway(gateway, profileKey) + void ensureGatewayForProfile(profileKey) + } catch { + $activeGatewayProfile.set('default') + } + } + + // Seed the working dir from the backend default on a fresh view (nothing + // open yet). Shared by boot + soft switch. + async function seedDefaultCwd() { + await ensureDefaultWorkspaceCwd() + const remoteDefault = await desktopDefaultCwd().catch(() => null) + + if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { + setCurrentCwd(remoteDefault.cwd) + setCurrentBranch(remoteDefault.branch || '') + } + } + + // Soft gateway-mode apply: main tore down the primary without reloading. + // Wipe session lists so skeletons retrigger, then re-dial in place. + const softSwitch = async () => { + if (cancelled) { + return + } + + $gatewaySwitching.set(true) + clearReconnectTimer() + reconnectAttempt = 0 + escalated = false + reauthNotified = false + wipeSessionListsForGatewaySwitch() + + try { + gateway.close() + closeSecondaryGateways() + + const conn = await desktop.getConnection() + + if (cancelled) { + return + } + + publish(conn) + const wsUrl = await resolveGatewayWsUrl(desktop, conn) + await gateway.connect(wsUrl) + + if (cancelled) { + return + } + + await adoptPrimaryProfile() + await seedDefaultCwd() + await callbacksRef.current.refreshHermesConfig().catch(() => undefined) + await callbacksRef.current.refreshSessions().catch(() => undefined) + completeDesktopBoot() + bootCompleted = true + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : String(err) + failDesktopBoot(message) + notifyError(err, translateNow('boot.errors.desktopBootFailed')) + setSessionsLoading(false) + } + } finally { + $gatewaySwitching.set(false) + } + } + + const offBootProgress = desktop.onBootProgress(payload => { + // Soft switch / post-boot startHermes re-emits progress — ignore so the + // cold-boot CONNECTING overlay stays down. Errors still surface. + if ($gatewaySwitching.get() || bootCompleted) { + if (payload.error) { + applyDesktopBootProgress(payload) + } + + return + } + + applyDesktopBootProgress(payload) + }) void desktop .getBootProgress() .then(snapshot => applyDesktopBootProgress(snapshot)) @@ -258,7 +349,7 @@ export function useGatewayBoot({ if (bootCompleted) { completeDesktopBoot() } - } else if (bootCompleted && (st === 'closed' || st === 'error')) { + } else if (bootCompleted && !$gatewaySwitching.get() && (st === 'closed' || st === 'error')) { // The socket dropped after a healthy boot (typically sleep/wake). Try // to bring it back instead of leaving the composer stuck disabled. scheduleReconnect() @@ -270,6 +361,7 @@ export function useGatewayBoot({ // Wake signals: power resume (macOS/Windows), network coming back, and the // window regaining focus/visibility. Each nudges an immediate reconnect. const offPowerResume = desktop.onPowerResume?.(() => reconnectNow()) + const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch()) const onOnline = () => reconnectNow() @@ -319,6 +411,10 @@ export function useGatewayBoot({ }) const offExit = desktop.onBackendExit(() => { + if ($gatewaySwitching.get()) { + return + } + if ($desktopBoot.get().running || $desktopBoot.get().visible) { failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup')) } @@ -357,31 +453,14 @@ export function useGatewayBoot({ return } - // Record which profile the primary (window) backend booted as, so - // same-profile resumes are no-op swaps and any reconnect targets the - // right backend. Best-effort: a missing preference means "default". - try { - const pref = await desktop.profile?.get?.() - const profileKey = (pref?.profile ?? '').trim() || 'default' - $activeGatewayProfile.set(profileKey) - setPrimaryGateway(gateway, profileKey) - void ensureGatewayForProfile(profileKey) - } catch { - $activeGatewayProfile.set('default') - } + await adoptPrimaryProfile() setDesktopBootStep({ phase: 'renderer.config', message: translateNow('boot.steps.loadingSettings'), progress: 97 }) - await ensureDefaultWorkspaceCwd() - const remoteDefault = await desktopDefaultCwd().catch(() => null) - - if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) { - setCurrentCwd(remoteDefault.cwd) - setCurrentBranch(remoteDefault.branch || '') - } + await seedDefaultCwd() await callbacksRef.current.refreshHermesConfig() @@ -411,6 +490,7 @@ export function useGatewayBoot({ return () => { cancelled = true + $gatewaySwitching.set(false) clearReconnectTimer() clearInterval(keepaliveTimer) offWorking() @@ -419,6 +499,7 @@ export function useGatewayBoot({ window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisible) offPowerResume?.() + offConnectionApplied?.() offState() offEvent() offExit() diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index aae1c75efe29..2be223b341ec 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,19 +3,25 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global' +import { Tip } from '@/components/ui/tooltip' +import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' -import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons' +import { ExternalLink } from '@/lib/external-link' +import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' +import { previewGatewaySwitch } from '@/store/gateway-switch' import { notify, notifyError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' -type Mode = 'local' | 'remote' +type Mode = 'local' | 'remote' | 'cloud' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' +// Hermes Cloud discovery lifecycle for the cloud-mode panel. +type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error' interface GatewaySettingsState { envOverride: boolean @@ -25,6 +31,7 @@ interface GatewaySettingsState { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + cloudOrg: string } const EMPTY_STATE: GatewaySettingsState = { @@ -34,13 +41,15 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, - remoteUrl: '' + remoteUrl: '', + cloudOrg: '' } function ModeCard({ active, description, disabled, + hint, icon: Icon, onSelect, title @@ -48,6 +57,7 @@ function ModeCard({ active: boolean description: string disabled?: boolean + hint?: string icon: typeof Monitor onSelect: () => void title: string @@ -55,22 +65,29 @@ function ModeCard({ return ( @@ -100,11 +117,38 @@ export function GatewaySettings() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) + const [previewingSwitch, setPreviewingSwitch] = useState(false) const [signingIn, setSigningIn] = useState(false) const [state, setState] = useState(EMPTY_STATE) const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState(null) + // --- Hermes Cloud (cloud mode) state --- + // One portal session powers discovery + the silent per-agent cascade. These + // track the cloud panel: whether we're signed in, the discovered agent list, + // and which agent is mid-connect. + const [cloudSignedIn, setCloudSignedIn] = useState(false) + const [cloudSigningIn, setCloudSigningIn] = useState(false) + const [cloudAgents, setCloudAgents] = useState([]) + const [cloudDiscover, setCloudDiscover] = useState('idle') + const [cloudConnectingId, setCloudConnectingId] = useState(null) + // Multi-org users: when discovery returns needsOrgSelection, we hold the org + // list here and show a picker. `cloudOrg` is the chosen org slug/id (null = + // not yet chosen / single-org user). + const [cloudOrgs, setCloudOrgs] = useState([]) + const [cloudOrg, setCloudOrgState] = useState(null) + // Mirror the selected org into a ref so connect reads the CURRENT value, not a + // value captured in a stale render closure. discoverCloud() resolves the org + // asynchronously (from the NAS response) and a user can click Connect in the + // same render tick; without the ref, connectCloudAgent could persist a null + // org even though discovery just resolved one. Always set both together. + const cloudOrgRef = useRef(null) + + const setCloudOrg = (value: null | string) => { + cloudOrgRef.current = value + setCloudOrgState(value) + } + // Connection scope: null = the global/default connection (the original // behavior); a profile name = that profile's per-profile remote override, so // each profile can point at its own backend. @@ -163,6 +207,22 @@ export function GatewaySettings() { // OAuth login button or the session-token entry box. The effective auth mode // prefers a fresh probe result over the saved value. const trimmedUrl = state.remoteUrl.trim() + + // The dashboardUrl of the currently-connected cloud instance (the saved + // cloud connection's remoteUrl), normalized for comparison against each + // discovered agent's dashboardUrl so we can highlight the active one and hide + // its Connect button. Empty unless the saved connection is a cloud one. + // The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl + // (which lowercases the host through URL.toString()), but a discovered agent's + // dashboardUrl arrives raw from NAS — so normalize both sides the same way + // (trim, drop trailing slash, lowercase) or a host-casing difference would + // silently break the connected-highlight. + const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase() + const connectedCloudUrl = state.mode === 'cloud' ? normalizeCloudUrl(state.remoteUrl) : '' + + const isConnectedAgent = (agent: DesktopCloudAgent) => + Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl) + useEffect(() => { if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { setProbeStatus('idle') @@ -379,6 +439,234 @@ export function GatewaySettings() { } } + // --- Hermes Cloud handlers --- + + // Pull the discovered agent list over the shared portal session. Tolerant of + // a lapsed session: a needsCloudLogin error flips us back to signed-out. + // `org` scopes discovery for multi-org users; when discovery comes back with + // needsOrgSelection we surface the org list and show a picker instead. + const discoverCloud = async (org?: string) => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudDiscover('loading') + + try { + const result = await desktop.cloud.discover(org) + + if ('needsOrgSelection' in result && result.needsOrgSelection) { + // Multi-org user with no org chosen yet: show the picker. Don't clear a + // previously-chosen org list on a refresh. + setCloudOrgs(result.orgs) + setCloudAgents([]) + setCloudDiscover('done') + + return + } + + // Single org (or org now chosen): we have agents. + setCloudAgents('agents' in result ? result.agents : []) + + // Record the org AUTHORITATIVELY from the response (NAS echoes the org the + // list was scoped to), falling back to the org we requested. This is what + // gets persisted on connect, so it must be set even on single-membership + // auto-resolve where no picker ran and no `org` arg was passed. + const resolvedOrgRef = 'org' in result && result.org ? (result.org.slug ?? result.org.id) : null + + if (resolvedOrgRef) { + setCloudOrg(resolvedOrgRef) + } else if (org) { + setCloudOrg(org) + } + + setCloudDiscover('done') + } catch (err) { + setCloudAgents([]) + setCloudDiscover('error') + + // A lapsed/absent portal session means we're effectively signed out. + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudDiscoverFailed) + } + } + + // User picked an org from the multi-org picker: remember it and re-run + // discovery scoped to it. + const selectCloudOrg = (org: DesktopCloudOrg) => { + const ref = org.slug ?? org.id + setCloudOrg(ref) + void discoverCloud(ref) + } + + // "Change org": clear the selected org and re-discover with no org arg. A + // multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves + // back to their one org. Also clear the agent list so the current org's + // agents don't linger under the picker while discovery re-runs. + const changeCloudOrg = () => { + setCloudOrg(null) + setCloudAgents([]) + void discoverCloud() + } + + // On entering cloud mode (or scope change), read the portal session status and + // auto-discover when already signed in, so the picker is populated on open. + useEffect(() => { + if (state.mode !== 'cloud') { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + let cancelled = false + desktop.cloud + .status() + .then(status => { + if (cancelled) { + return + } + + setCloudSignedIn(status.signedIn) + + if (status.signedIn) { + // Restore the persisted org (if any) so we reopen straight into that + // org's agent list instead of the picker; discoverCloud(org) also + // records it as the selected org. Empty → normal discovery (single-org + // resolves automatically; multi-org shows the picker). + const savedOrg = state.cloudOrg || '' + + if (savedOrg) { + setCloudOrg(savedOrg) + } + + void discoverCloud(savedOrg || undefined) + } else { + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + } + }) + .catch(() => { + if (!cancelled) { + setCloudSignedIn(false) + } + }) + + return () => void (cancelled = true) + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only + }, [state.mode, scope]) + + const cloudSignIn = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + const result = await desktop.cloud.login() + setCloudSignedIn(result.signedIn) + + if (result.signedIn) { + await discoverCloud() + } + } catch (err) { + notifyError(err, g.cloudSignInFailed) + } finally { + setCloudSigningIn(false) + } + } + + const cloudSignOut = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + await desktop.cloud.logout() + setCloudSignedIn(false) + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) + } catch (err) { + notifyError(err, g.signOutFailed) + } finally { + setCloudSigningIn(false) + } + } + + // Select a discovered agent: drive the silent per-agent cascade (no second + // prompt — the shared portal session auto-approves), then persist a cloud-mode + // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). + const connectCloudAgent = async (agent: DesktopCloudAgent) => { + if (!agent.dashboardUrl) { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudConnectingId(agent.id) + + try { + const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + + if (!result.connected) { + notify({ + kind: 'warning', + title: t.boot.failure.signInIncompleteTitle, + message: t.boot.failure.signInIncompleteMessage + }) + + return + } + + // Persist a cloud-mode connection (remote-shaped, oauth) and soft-reconnect. + // Include the selected org so Settings reopens into the same org + instance. + // Read the REF (not the cloudOrg state) so a just-resolved org from + // discovery in this same render tick is captured, not a stale null. + const next = await desktop.applyConnectionConfig({ + mode: 'cloud', + profile: scope ?? undefined, + remoteAuthMode: 'oauth', + remoteUrl: agent.dashboardUrl, + cloudOrg: cloudOrgRef.current ?? undefined + }) + + setState(next) + notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) + } catch (err) { + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudConnectFailed) + } finally { + setCloudConnectingId(null) + } + } + const testRemote = async () => { if (!canUseRemote) { notify({ @@ -465,131 +753,306 @@ export function GatewaySettings() { ) : null} -
- setState(current => ({ ...current, mode: 'local' }))} - title={g.localTitle} - /> - setState(current => ({ ...current, mode: 'remote' }))} - title={g.remoteTitle} - /> +
+
+ {g.modeTitle} +
+
+ setState(current => ({ ...current, mode: 'local' }))} + title={g.localTitle} + /> + setState(current => ({ ...current, mode: 'cloud' }))} + title={g.cloudTitle} + /> + setState(current => ({ ...current, mode: 'remote' }))} + title={g.remoteTitle} + /> +
-
- setState(current => ({ ...current, remoteUrl: event.target.value }))} - placeholder="https://gateway.example.com/hermes" - value={state.remoteUrl} - /> - } - description={g.remoteUrlDesc} - title={g.remoteUrlTitle} - /> - - {state.mode === 'remote' && probeStatus === 'probing' ? ( -
- - {g.probing} -
- ) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( -
- - {g.probeError} -
- ) : null} - - {/* OAuth / password gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + {/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker + whose selection drives the silent per-agent cascade + a cloud + connection. Replaces the URL/token form while in cloud mode. */} + {state.mode === 'cloud' && !state.envOverride ? ( +
- {g.signedIn} + {g.cloudSignedIn} -
) : ( - ) } - description={ - oauthConnected - ? isPasswordProvider - ? g.authSignedInPassword - : g.authSignedInOauth - : isPasswordProvider - ? g.authNeedsPassword - : g.authNeedsOauth(providerLabel) - } - title={g.authTitle} + description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn} + title={g.cloudSignInTitle} /> - ) : null} - {/* Session-token gateways: keep the existing token entry box. */} - {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + {cloudSignedIn ? ( + cloudOrgs.length > 0 && !cloudOrg ? ( + // Multi-org user who hasn't picked an org yet: show the org picker + // instead of the agent list. Selecting one re-runs discovery + // scoped to it. +
+
+ {g.cloudOrgPickerTitle} +
+
+ {cloudOrgs.map(orgEntry => ( + selectCloudOrg(orgEntry)} size="sm"> + {g.cloudOrgSelect} + + } + description={g.cloudOrgRole(orgEntry.role)} + key={orgEntry.id} + title={orgEntry.name} + /> + ))} +
+
+ ) : ( +
+
+
+ {g.cloudAgentsTitle} +
+
+ {cloudOrg ? ( + // Let the user switch orgs. Gating on cloudOrgs.length would + // hide this after a restore-open (which discovers straight + // into the saved org and never populates the org list). So + // show it whenever an org is selected: clicking clears the + // org and re-runs discovery with no org arg — a multi-org + // user gets the picker (NAS 409), a single-org user simply + // auto-resolves back to their one org (harmless). + + ) : null} + +
+
+ + {cloudDiscover === 'loading' ? ( +
+ + {g.cloudLoadingAgents} +
+ ) : cloudAgents.length === 0 ? ( +
+ + + {g.cloudNoAgents.before} + + {g.cloudNoAgents.linkText} + + {g.cloudNoAgents.after} + +
+ ) : ( +
+ {cloudAgents.map(agent => { + const connected = isConnectedAgent(agent) + + return ( +
+ + + {g.cloudConnectedPill} + + ) : ( + + ) + } + description={g.cloudStatusLabel(agent.dashboardGatewayState)} + title={agent.name} + /> +
+ ) + })} +
+ )} +
+ ) + ) : null} +
+ ) : null} + + {state.mode === 'remote' && !state.envOverride ? ( +
setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken - } - type="password" - value={remoteToken} + onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} + placeholder="https://gateway.example.com/hermes" + value={state.remoteUrl} /> } - description={g.tokenDesc} - title={g.tokenTitle} + description={g.remoteUrlDesc} + title={g.remoteUrlTitle} /> - ) : null} -
+ + {state.mode === 'remote' && probeStatus === 'probing' ? ( +
+ + {g.probing} +
+ ) : null} + + {state.mode === 'remote' && probeStatus === 'error' ? ( +
+ + {g.probeError} +
+ ) : null} + + {/* OAuth / password gateways: present a sign-in button + connection status. */} + {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + + + {g.signedIn} + + +
+ ) : ( + + ) + } + description={ + oauthConnected + ? isPasswordProvider + ? g.authSignedInPassword + : g.authSignedInOauth + : isPasswordProvider + ? g.authNeedsPassword + : g.authNeedsOauth(providerLabel) + } + title={g.authTitle} + /> + ) : null} + + {/* Session-token gateways: keep the existing token entry box. */} + {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet + ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) + : g.pasteSessionToken + } + type="password" + value={remoteToken} + /> + } + description={g.tokenDesc} + title={g.tokenTitle} + /> + ) : null} + + ) : null} {lastTest ?
{lastTest}
: null} -
- - - -
+ {/* Test/Save apply to local + remote. Cloud connects via the agent picker + above (which applies a cloud connection on select), so its only + bottom-row action would be redundant — hidden in cloud mode. */} + {state.mode !== 'cloud' ? ( +
+ {state.mode === 'remote' ? ( + + ) : null} + + +
+ ) : null}
+ {import.meta.env.DEV ? ( + { + setPreviewingSwitch(true) + void previewGatewaySwitch().finally(() => setPreviewingSwitch(false)) + }} + size="sm" + variant="textStrong" + > + {previewingSwitch ? : null} + Preview soft switch + + } + description="Wipe session lists so sidebar skeletons retrigger — no real backend teardown." + title="Dev · soft switch" + /> + ) : null}
) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index bb17f79c3cd1..5aca0efbe7d2 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -124,7 +124,7 @@ export function BootFailureOverlay() { const switchToLocalGateway = async () => { setBusy('local') - // applyConnectionConfig reloads the window from the main process. + // Soft apply: tears down the primary and re-dials in place (shell stays). await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined) setBusy(null) } diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 613b43f65353..bf6f4f7ab91f 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -14,6 +14,7 @@ function config(overrides: Partial = {}): DesktopConnec remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: 'https://box:9119', + cloudOrg: '', ...overrides } } @@ -31,6 +32,16 @@ describe('isRemoteReauthFailure', () => { expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false) }) + it('true for a cloud connection with a lapsed session (cloud resolves to remote oauth)', () => { + // A 'cloud' connection is a remote oauth backend under the hood (Q6), so a + // lapsed cloud session is the same reauth failure as a lapsed remote one. + expect(isRemoteReauthFailure(config({ mode: 'cloud' }))).toBe(true) + }) + + it('false for a connected cloud session', () => { + expect(isRemoteReauthFailure(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(false) + }) + it('false for a token (non-gated) remote gateway', () => { expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false) }) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 3aeae7846e4c..fd2e01ffcb67 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -31,14 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = { // dashboard restarted) and the local-recovery buttons (Retry/Repair) can't // fix it — only re-establishing the remote session can. A connected oauth // session, or a token/local gateway, boots for some other reason the -// local-recovery buttons address, so those return false here. +// local-recovery buttons address, so those return false here. 'cloud' counts +// as remote here — it resolves to a remote oauth backend (cloud-auto-discovery +// Q6), so a lapsed cloud session is the same reauth failure. export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { if (!config) { return false } return ( - config.mode === 'remote' && + (config.mode === 'remote' || config.mode === 'cloud') && config.remoteAuthMode === 'oauth' && !config.remoteOauthConnected && Boolean(config.remoteUrl) diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index e5e493159853..40ef1ff607b8 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -2,6 +2,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $desktopBoot } from '@/store/boot' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $desktopOnboarding } from '@/store/onboarding' import { setGatewayState } from '@/store/session' @@ -23,6 +24,7 @@ import { GatewayConnectingOverlay } from './gateway-connecting-overlay' function resetStores() { setGatewayState('idle') + $gatewaySwitching.set(false) $desktopBoot.set({ error: null, fakeMode: false, @@ -125,6 +127,35 @@ describe('connecting overlay vs recovery surface', () => { expect(isRecoveryShown()).toBe(false) }) + it('soft gateway switch keeps the shell — no fullscreen CONNECTING', () => { + setGatewayState('open') + const { rerender } = render( + <> + + + + ) + + $gatewaySwitching.set(true) + $desktopBoot.set({ + ...$desktopBoot.get(), + running: true, + visible: true, + progress: 4, + error: null + }) + setGatewayState('closed') + rerender( + <> + + + + ) + + expect(isConnectingShown()).toBe(false) + expect(isRecoveryShown()).toBe(false) + }) + it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => { // Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of // failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index bff722b9a28f..aa2065147502 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { $desktopBoot } from '@/store/boot' +import { $gatewaySwitching } from '@/store/gateway-switch' import { $gatewayState } from '@/store/session' // Static, always-legible prefix; only TAIL ever scrambles. Splitting them at @@ -48,9 +49,17 @@ function scrambledTail(resolvedCount: number): string { export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) + const gatewaySwitching = useStore($gatewaySwitching) const [previewing] = useState(forcedPreview) const [tail, setTail] = useState(TAIL) const [phase, setPhase] = useState('live') + // Once cold boot has completed once, never resurrect the fullscreen overlay + // — soft gateway switches keep the shell and reskeleton the sidebar instead. + const coldBootDoneRef = useRef(false) + + if (!boot.running && boot.progress >= 100 && !boot.error) { + coldBootDoneRef.current = true + } // The full-screen connecting overlay is for initial boot only. After a // healthy boot, flaky networks / sleep-wake can drop the socket and flip the @@ -58,7 +67,12 @@ export function GatewayConnectingOverlay() { // the chat then — users should still be able to type drafts, open settings, // and recover instead of staring at a modal CONNECTING screen. const initialBootActive = boot.visible || boot.running || boot.progress < 100 - const connecting = gatewayState !== 'open' && !boot.error && initialBootActive + const connecting = + !coldBootDoneRef.current && + !gatewaySwitching && + gatewayState !== 'open' && + !boot.error && + initialBootActive // Latches once we've actually shown the overlay, so the brief frame where // gatewayState flips to "open" (connecting -> false) before the exit phase // kicks in doesn't unmount us and cause a flash. diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index 06abd4b7945c..10a107727a51 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -53,6 +53,14 @@ const buttonVariants = cva( 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]' } }, + compoundVariants: [ + // textStrong is a boxless link — size variants still inject px-*; strip + // inline padding so the underline sits flush with the label. + { + variant: 'textStrong', + class: 'px-0 has-[>svg]:px-0' + } + ], defaultVariants: { variant: 'default', size: 'default' diff --git a/apps/desktop/src/components/ui/tooltip.test.tsx b/apps/desktop/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000000..06087a696234 --- /dev/null +++ b/apps/desktop/src/components/ui/tooltip.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { Tip } from './tooltip' + +describe('Tip', () => { + afterEach(() => { + cleanup() + }) + + it('shows on pointer enter and dismisses on pointer leave', async () => { + render( + + + + ) + + const trigger = screen.getByRole('button', { name: 'layout' }) + + fireEvent.pointerMove(trigger, { pointerType: 'mouse' }) + expect((await screen.findByRole('tooltip')).textContent).toContain( + 'Layout editor — ⌘-click resets the layout' + ) + + fireEvent.pointerLeave(trigger) + await waitFor(() => { + expect(screen.queryByRole('tooltip')).toBeNull() + }) + }) + + it('never captures pointer events on the tip surface', async () => { + render( + + + + ) + + fireEvent.pointerMove(screen.getByRole('button', { name: 'target' }), { pointerType: 'mouse' }) + const tip = await screen.findByRole('tooltip') + // Role lives on the visually-hidden a11y node; the portaled content root + // is the data-slot wrapper that must stay click-through. + const content = tip.closest('[data-slot="tooltip-content"]') ?? tip.parentElement + expect(content?.className).toMatch(/pointer-events-none/) + }) + + it('renders the child alone when label is empty', () => { + render( + + + + ) + + expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() + expect(screen.queryByRole('tooltip')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index b3e012d976f8..d10535858690 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -3,8 +3,23 @@ import * as React from 'react' import { cn } from '@/lib/utils' -function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { - return +function TooltipProvider({ + delayDuration = 0, + // Tips are labels, not interactive surfaces. Hoverable content + Radix's + // pointer-grace bridge is what leaves tips stuck open — especially over + // Electron `-webkit-app-region: drag` chrome where pointermove never fires + // to clear the grace area. Default off so open state tracks the trigger only. + disableHoverableContent = true, + ...props +}: React.ComponentProps) { + return ( + + ) } function Tooltip({ ...props }: React.ComponentProps) { @@ -24,18 +39,23 @@ function TooltipContent({ return ( - {children} + {/* bg-foreground/text-background auto-inverts per theme. leading-normal + keeps lines readable; py-1 makes the cloned line-boxes overlap just + enough to read as one continuous fill (no gaps between lines). */} + + {children} + ) @@ -50,15 +70,15 @@ interface TipProps extends Omit{children} } return ( - - + + {children} {label} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 3c2e8fe8da21..a6f82fa309d9 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -55,6 +55,15 @@ declare global { probeConnectionConfig: (remoteUrl: string) => Promise oauthLoginConnectionConfig: (remoteUrl: string) => Promise oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise + // Hermes Cloud: one portal login powers discovery + silent per-agent + // sign-in (cloud-auto-discovery Phase 3). + cloud: { + status: () => Promise + login: () => Promise + logout: () => Promise + discover: (org?: string) => Promise + agentSignIn: (dashboardUrl: string) => Promise + } profile: { get: () => Promise // Persists the desktop's profile choice and relaunches the local @@ -172,6 +181,9 @@ declare global { onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void onBackendExit: (callback: (payload: BackendExit) => void) => () => void + // Soft gateway-mode apply: primary backend was torn down without a window + // reload. Wipe session lists (skeletons) and re-dial. + onConnectionApplied?: (callback: () => void) => () => void onPowerResume?: (callback: () => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise @@ -359,6 +371,9 @@ export interface DesktopUpdateProgress { export interface HermesConnection { baseUrl: string isFullscreen: boolean + // The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a + // 'cloud' saved-config entry resolves to a 'remote' connection under the hood + // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'. mode?: 'local' | 'remote' authMode?: 'oauth' | 'token' nativeOverlayWidth: number @@ -391,7 +406,12 @@ export interface DesktopActiveProfile { export interface DesktopConnectionConfig { envOverride: boolean - mode: 'local' | 'remote' + // The saved connection mode. 'cloud' is a Hermes Cloud connection: it carries + // a remote-shaped block (remoteUrl = the selected agent's dashboardUrl, + // remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into + // the cloud picker. Resolution treats cloud exactly as remote + // (cloud-auto-discovery Q3/Q6). + mode: 'local' | 'remote' | 'cloud' // The profile this config describes, or null for the global/default // connection. Per-profile entries let a profile point at its own backend. profile: null | string @@ -400,16 +420,23 @@ export interface DesktopConnectionConfig { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + // For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the + // connected instance was discovered under, so Settings → Gateway can reopen + // into that org. Empty string for remote/local. + cloudOrg: string } export interface DesktopConnectionConfigInput { - mode: 'local' | 'remote' + mode: 'local' | 'remote' | 'cloud' // When set, the save/apply/test targets this profile's per-profile remote // override instead of the global connection. profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string remoteUrl?: string + // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to + // persist so Settings can reopen into it. Ignored for remote/local modes. + cloudOrg?: string } export interface DesktopConnectionTestResult { @@ -448,6 +475,55 @@ export interface DesktopOauthLogoutResult { connected: boolean } +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- + +export interface DesktopCloudStatus { + // The portal base URL the desktop talks to (default or env-overridden). + portalBaseUrl: string + // Whether the OAuth partition holds a live Nous portal (Privy) session — the + // portal authenticates via Privy, so this reflects the privy-token cookie, NOT + // the hermes gateway session cookies. See cookiesHavePrivySession. + signedIn: boolean +} + +// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents. +export interface DesktopCloudAgent { + id: string + name: string + status: string + // null until the agent has a provisioned dashboard (show "provisioning…"). + dashboardUrl: string | null + // "active" | "degraded" | "down" | "unknown". + dashboardGatewayState: string +} + +// An org the signed-in user belongs to — for the org picker shown when a +// multi-org user's discovery call needs disambiguation (NAS 409). +export interface DesktopCloudOrg { + id: string + slug: string | null + name: string + isPersonal: boolean + // "OWNER" | "MEMBER". + role: string +} + +// Discovery result: either the agent list, OR a request to pick an org first +// (multi-org user, no org chosen yet). The renderer shows a picker on the +// latter and re-calls discover(org). On the agents branch, `org` echoes the +// authoritatively-resolved org the list was scoped to (from NAS), so the +// desktop persists it without relying on transient picker state. +export type DesktopCloudDiscoverResult = + | { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false } + | { needsOrgSelection: true; orgs: DesktopCloudOrg[] } + +export interface DesktopCloudAgentSignInResult { + // The agent gateway base URL the silent sign-in targeted. + baseUrl: string + // Whether the agent's gateway session cookie landed (silent cascade done). + connected: boolean +} + export interface DesktopBootProgress { error: string | null fakeMode: boolean diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0ed3e0ff8810..b4520ec13c60 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -530,11 +530,44 @@ export const en: Translations = { envOverrideTitle: 'Environment variables are controlling this desktop session.', envOverrideDesc: 'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.', + modeTitle: 'Connection mode', localTitle: 'Local gateway', localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.', remoteTitle: 'Remote gateway', - remoteDesc: - 'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + remoteDesc: 'Connect this desktop shell to a remote Hermes backend.', + remoteAuthHint: + 'Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + cloudTitle: 'Hermes Cloud', + cloudDesc: 'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste.', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Sign in to Hermes Cloud', + cloudSignedIn: 'Signed in to Hermes Cloud', + cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.', + cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.', + cloudAgentsTitle: 'Your agents', + cloudOrgPickerTitle: 'Choose an organization', + cloudOrgSelect: 'Select', + cloudOrgChange: 'Change org', + cloudOrgRole: role => `Role: ${role}`, + cloudLoadingAgents: 'Loading your agents…', + cloudNoAgents: { + before: 'No agents found on this account. Create one in the ', + linkText: 'Nous portal', + after: ', then refresh.' + }, + cloudRefresh: 'Refresh', + cloudConnect: 'Connect', + cloudConnecting: 'Connecting…', + cloudDiscoverFailed: 'Could not load your Hermes Cloud agents', + cloudConnectFailed: 'Could not connect to that agent', + cloudSignInFailed: 'Hermes Cloud sign-in failed', + cloudSignedOutTitle: 'Signed out of Hermes Cloud', + cloudSignedOutMessage: 'Cleared the Hermes Cloud session.', + cloudConnectedTitle: 'Connected', + cloudConnectedPill: 'Connected', + cloudConnectedTo: name => `Connected to ${name}.`, + cloudAgentProvisioning: 'Provisioning…', + cloudStatusLabel: status => `Status: ${status}`, remoteUrlTitle: 'Remote URL', remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.', probing: 'Checking how this gateway authenticates…', @@ -568,7 +601,7 @@ export const en: Translations = { enterUrlFirst: 'Enter a remote URL first.', restartingTitle: 'Gateway connection restarting', savedTitle: 'Gateway settings saved', - restartingMessage: 'Hermes Desktop will reconnect using the saved settings.', + restartingMessage: 'Hermes Desktop will reconnect using the saved settings — the shell stays open.', savedMessage: 'Saved for the next restart.', connectedTo: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, reachableTitle: 'Remote gateway reachable', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5c10c3d6873f..031f5c78f6d2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -442,10 +442,39 @@ export interface Translations { profileConnection: (profile: string) => string envOverrideTitle: string envOverrideDesc: string + modeTitle: string localTitle: string localDesc: string remoteTitle: string remoteDesc: string + remoteAuthHint: string + cloudTitle: string + cloudDesc: string + cloudSignInTitle: string + cloudSignIn: string + cloudSignedIn: string + cloudNeedsSignIn: string + cloudSignedInDesc: string + cloudAgentsTitle: string + cloudOrgPickerTitle: string + cloudOrgSelect: string + cloudOrgChange: string + cloudOrgRole: (role: string) => string + cloudLoadingAgents: string + cloudNoAgents: { before: string; linkText: string; after: string } + cloudRefresh: string + cloudConnect: string + cloudConnecting: string + cloudDiscoverFailed: string + cloudConnectFailed: string + cloudSignInFailed: string + cloudSignedOutTitle: string + cloudSignedOutMessage: string + cloudConnectedTitle: string + cloudConnectedPill: string + cloudConnectedTo: (name: string) => string + cloudAgentProvisioning: string + cloudStatusLabel: (status: string) => string remoteUrlTitle: string remoteUrlDesc: string probing: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 84b9f54e281d..350a50311e0e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -720,11 +720,43 @@ export const zh: Translations = { profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。设为本地即可继承默认连接。`, envOverrideTitle: '环境变量正在控制此桌面会话。', envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。', + modeTitle: '连接模式', localTitle: '本地网关', localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。', remoteTitle: '远程网关', - remoteDesc: - '将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + remoteDesc: '将此桌面外壳连接到远程 Hermes 后端。', + remoteAuthHint: '托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + cloudTitle: 'Hermes Cloud', + cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: '登录 Hermes Cloud', + cloudSignedIn: '已登录 Hermes Cloud', + cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。', + cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。', + cloudAgentsTitle: '你的智能体', + cloudOrgPickerTitle: '选择一个组织', + cloudOrgSelect: '选择', + cloudOrgChange: '切换组织', + cloudOrgRole: role => `角色:${role}`, + cloudLoadingAgents: '正在加载你的智能体…', + cloudNoAgents: { + before: '此账户下未找到智能体。请在', + linkText: 'Nous 门户', + after: '中创建一个,然后刷新。' + }, + cloudRefresh: '刷新', + cloudConnect: '连接', + cloudConnecting: '正在连接…', + cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体', + cloudConnectFailed: '无法连接到该智能体', + cloudSignInFailed: 'Hermes Cloud 登录失败', + cloudSignedOutTitle: '已退出 Hermes Cloud', + cloudSignedOutMessage: '已清除 Hermes Cloud 会话。', + cloudConnectedTitle: '已连接', + cloudConnectedPill: '已连接', + cloudConnectedTo: name => `已连接到 ${name}。`, + cloudAgentProvisioning: '正在配置…', + cloudStatusLabel: status => `状态:${status}`, remoteUrlTitle: '远程 URL', remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。', probing: '正在检查此网关的认证方式…', @@ -757,7 +789,7 @@ export const zh: Translations = { enterUrlFirst: '请先输入远程 URL。', restartingTitle: '网关连接正在重启', savedTitle: '网关设置已保存', - restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接。', + restartingMessage: 'Hermes Desktop 将使用已保存设置重新连接(界面保持打开)。', savedMessage: '已保存,下一次重启生效。', connectedTo: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, reachableTitle: '远程网关可访问', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e863aa392804..42073f025109 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -27,6 +27,7 @@ import { IconCircle as CircleIcon, IconClipboard as Clipboard, IconClock as Clock, + IconCloud as Cloud, IconCommand as Command, IconCopy as Copy, IconCopy as CopyIcon, @@ -142,6 +143,7 @@ export { CircleIcon, Clipboard, Clock, + Cloud, Command, Copy, CopyIcon, diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts new file mode 100644 index 000000000000..b50cfb059afa --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $sessionsLimit, resetSessionsLimit, SIDEBAR_SESSIONS_PAGE_SIZE } from '@/store/layout' +import { + $cronSessions, + $freshDraftReady, + $messagingSessions, + $sessions, + $sessionsLoading, + $sessionsTotal, + setCronSessions, + setFreshDraftReady, + setMessagingSessions, + setSessions, + setSessionsLoading, + setSessionsTotal +} from '@/store/session' + +import { $gatewaySwitching, previewGatewaySwitch, wipeSessionListsForGatewaySwitch } from './gateway-switch' + +vi.mock('@/lib/query-client', () => ({ + queryClient: { invalidateQueries: vi.fn() } +})) + +describe('wipeSessionListsForGatewaySwitch', () => { + beforeEach(() => { + $gatewaySwitching.set(false) + setSessions([{ id: 's1', title: 'old', profile: 'default' } as never]) + setSessionsTotal(1) + setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never]) + setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never]) + setSessionsLoading(false) + setFreshDraftReady(false) + $sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3) + }) + + afterEach(() => { + resetSessionsLimit() + setSessions([]) + setCronSessions([]) + setMessagingSessions([]) + setSessionsLoading(true) + $gatewaySwitching.set(false) + }) + + it('clears lists and arms loading so sidebar skeletons retrigger', () => { + wipeSessionListsForGatewaySwitch() + + expect($sessions.get()).toEqual([]) + expect($sessionsTotal.get()).toBe(0) + expect($cronSessions.get()).toEqual([]) + expect($messagingSessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(true) + expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE) + expect($freshDraftReady.get()).toBe(true) + }) + + it('previewGatewaySwitch holds skeletons then clears loading', async () => { + await previewGatewaySwitch(20) + + expect($sessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(false) + expect($gatewaySwitching.get()).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts new file mode 100644 index 000000000000..44be58186d0f --- /dev/null +++ b/apps/desktop/src/store/gateway-switch.ts @@ -0,0 +1,83 @@ +import { atom } from 'nanostores' + +import { queryClient } from '@/lib/query-client' +import { resetSessionsLimit } from '@/store/layout' +import { + setActiveSessionId, + setAttentionSessionIds, + setCronSessions, + setFreshDraftReady, + setMessages, + setMessagingPlatformTotals, + setMessagingSessions, + setMessagingTruncated, + setSelectedStoredSessionId, + setSessionProfileTotals, + setSessions, + setSessionsLoading, + setSessionsTotal, + setWorkingSessionIds +} from '@/store/session' + +// True while a soft gateway-mode apply is mid-flight (wipe → re-dial). Lets the +// boot hook suppress the backend-exit toast and keeps the cold-boot CONNECTING +// overlay from resurrecting when startHermes re-emits boot progress. +export const $gatewaySwitching = atom(false) + +const PREVIEW_HOLD_MS = 1400 + +/** + * Clear gateway-bound session UI so sidebar skeletons retrigger. + * + * Sessions live in nanostores (not React Query) — refreshSessions merges into + * the existing list, so without an explicit wipe a soft switch would keep + * painting the previous gateway's rows. RQ caches (settings/config/skills) are + * invalidated separately; the live session list is this path. + * + * Does NOT call requestFreshSession() — that navigates to NEW_CHAT and would + * close route overlays (Settings). Clear chat state in place; leave the URL + * alone so the user stays where they were (e.g. mid-Gateway settings). + */ +export function wipeSessionListsForGatewaySwitch(): void { + setSessions([]) + setSessionsTotal(0) + setSessionProfileTotals({}) + setCronSessions([]) + setMessagingSessions([]) + setMessagingPlatformTotals({}) + setMessagingTruncated(false) + setWorkingSessionIds([]) + setAttentionSessionIds([]) + setSessionsLoading(true) + resetSessionsLimit() + + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setMessages([]) + setFreshDraftReady(true) + + void queryClient.invalidateQueries() +} + +/** + * Dev review beat: wipe → skeletons for PREVIEW_HOLD_MS → clear loading. + * Does not tear down a real backend. Fired from the Settings button (Electron + * has no easy `?query=` entry). + */ +export async function previewGatewaySwitch(holdMs = PREVIEW_HOLD_MS): Promise { + if ($gatewaySwitching.get()) { + return + } + + $gatewaySwitching.set(true) + wipeSessionListsForGatewaySwitch() + + try { + await new Promise(resolve => { + window.setTimeout(resolve, holdMs) + }) + } finally { + setSessionsLoading(false) + $gatewaySwitching.set(false) + } +} diff --git a/cli.py b/cli.py index 069b3c616c1e..379ca15df1ac 100644 --- a/cli.py +++ b/cli.py @@ -543,6 +543,8 @@ def load_cli_config() -> Dict[str, Any]: if key == "model": continue # Already handled above if key in file_config: + if isinstance(defaults[key], dict) and file_config[key] is None: + continue if isinstance(defaults[key], dict) and isinstance(file_config[key], dict): defaults[key].update(file_config[key]) else: diff --git a/cron/jobs.py b/cron/jobs.py index 76cfc60cbf5d..1ea3361fdef9 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -7,6 +7,8 @@ import contextlib import copy +from contextvars import ContextVar +from dataclasses import dataclass import json import logging import shutil @@ -62,6 +64,9 @@ # the default root: that re-breaks per-profile isolation. See also the dynamic # `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py. HERMES_DIR = get_hermes_home().resolve() +# These constants remain the default-profile fallback and a compatibility +# surface for existing callers/tests. Cross-profile callers must scope paths +# with use_cron_store() instead of mutating them process-wide. CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" # Heartbeat file the in-process ticker touches on every loop iteration. The @@ -96,6 +101,50 @@ OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 + +@dataclass(frozen=True) +class _CronStorePaths: + cron_dir: Path + jobs_file: Path + output_dir: Path + + +_cron_store_override: ContextVar[Optional[_CronStorePaths]] = ContextVar( + "cron_store_override", + default=None, +) + + +def _current_cron_store() -> _CronStorePaths: + """Return paths pinned to this execution context's profile.""" + override = _cron_store_override.get() + if override is not None: + return override + return _CronStorePaths(CRON_DIR, JOBS_FILE, OUTPUT_DIR) + + +@contextlib.contextmanager +def use_cron_store(home: Union[str, Path]): + """Route cron storage to ``home`` without mutating process globals.""" + cron_dir = Path(home).expanduser().resolve() / "cron" + token = _cron_store_override.set( + _CronStorePaths( + cron_dir=cron_dir, + jobs_file=cron_dir / "jobs.json", + output_dir=cron_dir / "output", + ) + ) + try: + yield + finally: + _cron_store_override.reset(token) + + +def get_cron_output_dir() -> Path: + """Return the output directory for the active cron store context.""" + return _current_cron_store().output_dir + + # Fallback stale-recovery window for a one-shot's running-claim (#59229) when # the cron inactivity timeout is disabled (HERMES_CRON_TIMEOUT=0 → unlimited), # in which case no finite run bound exists to derive from. Also acts as the @@ -143,9 +192,29 @@ def _oneshot_run_claim_ttl_seconds() -> float: ) +def _job_running_in_this_process(job_id: str) -> bool: + """Return True when the scheduler in THIS process is still running ``job_id``. + + Direct liveness signal for stale-entry recovery (#62002): the run_claim + TTL alone cannot distinguish "the claiming tick died" from "the run is + alive but slow" — a run stalled on network I/O (or a laptop that slept + mid-run) legitimately outlives the TTL. The in-process ticker and the run + share this process, so the scheduler's running set settles the common + single-gateway case without any claim-age guesswork. + + Imported lazily: the scheduler imports this module at load, so a + module-level import here would be circular. + """ + try: + from cron.scheduler import get_running_job_ids + return job_id in get_running_job_ids() + except Exception: + return False + + def _jobs_lock_file() -> Path: """Return the advisory lock path for the current cron directory.""" - return CRON_DIR / ".jobs.lock" + return _current_cron_store().cron_dir / ".jobs.lock" @contextlib.contextmanager @@ -265,7 +334,7 @@ def _job_output_dir(job_id: str) -> Path: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") if Path(text).is_absolute() or Path(text).drive: raise ValueError(f"Invalid cron job id for output path: {job_id!r}") - return OUTPUT_DIR / text + return _current_cron_store().output_dir / text def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: @@ -372,10 +441,11 @@ def _secure_file(path: Path): def ensure_dirs(): """Ensure cron directories exist with secure permissions.""" - CRON_DIR.mkdir(parents=True, exist_ok=True) - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - _secure_dir(CRON_DIR) - _secure_dir(OUTPUT_DIR) + store = _current_cron_store() + store.cron_dir.mkdir(parents=True, exist_ok=True) + store.output_dir.mkdir(parents=True, exist_ok=True) + _secure_dir(store.cron_dir) + _secure_dir(store.output_dir) # ============================================================================= @@ -559,7 +629,7 @@ def _recoverable_oneshot_run_at( their requested minute still run on the next tick. Once a one-shot has already run, it is never eligible again. """ - if schedule.get("kind") != "once": + if not isinstance(schedule, dict) or schedule.get("kind") != "once": return None if last_run_at: return None @@ -568,7 +638,10 @@ def _recoverable_oneshot_run_at( if not run_at: return None - run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + try: + run_at_dt = _ensure_aware(datetime.fromisoformat(run_at)) + except Exception: + return None if run_at_dt >= now - timedelta(seconds=ONESHOT_GRACE_SECONDS): return run_at return None @@ -592,16 +665,18 @@ def _compute_grace_seconds(schedule: dict) -> int: return max(MIN_GRACE, min(grace, MAX_GRACE)) if kind == "cron" and HAS_CRONITER: - try: - now = _hermes_now() - cron = croniter(schedule["expr"], now) - first = cron.get_next(datetime) - second = cron.get_next(datetime) - period_seconds = int((second - first).total_seconds()) - grace = period_seconds // 2 - return max(MIN_GRACE, min(grace, MAX_GRACE)) - except Exception: - pass + expr = schedule.get("expr") + if expr: + try: + now = _hermes_now() + cron = croniter(expr, now) + first = cron.get_next(datetime) + second = cron.get_next(datetime) + period_seconds = int((second - first).total_seconds()) + grace = period_seconds // 2 + return max(MIN_GRACE, min(grace, MAX_GRACE)) + except Exception: + pass return MIN_GRACE @@ -614,28 +689,41 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None """ now = _hermes_now() - if schedule["kind"] == "once": + if not isinstance(schedule, dict): + return None + kind = schedule.get("kind") + if kind is None: + return None + + if kind == "once": return _recoverable_oneshot_run_at(schedule, now, last_run_at=last_run_at) - elif schedule["kind"] == "interval": - minutes = schedule["minutes"] + elif kind == "interval": + minutes = schedule.get("minutes") + if minutes is None: + return None if last_run_at: - # Next run is last_run + interval - last = _ensure_aware(datetime.fromisoformat(last_run_at)) - next_run = last + timedelta(minutes=minutes) + try: + last = _ensure_aware(datetime.fromisoformat(last_run_at)) + next_run = last + timedelta(minutes=minutes) + except Exception: + next_run = now + timedelta(minutes=minutes) else: # First run is now + interval next_run = now + timedelta(minutes=minutes) return next_run.isoformat() - elif schedule["kind"] == "cron": + elif kind == "cron": + expr = schedule.get("expr") + if not expr: + return None if not HAS_CRONITER: logger.warning( "Cannot compute next run for cron schedule %r: 'croniter' is " "not installed. croniter is a core dependency as of v0.9.x; " "reinstall hermes-agent or run 'pip install croniter' in your " "runtime env.", - schedule.get("expr"), + expr, ) return None # Use last_run_at as the croniter base when available, consistent @@ -644,8 +732,11 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None # rather than to an arbitrary restart time. base_time = now if last_run_at: - base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) - cron = croniter(schedule["expr"], base_time) + try: + base_time = _ensure_aware(datetime.fromisoformat(last_run_at)) + except Exception: + base_time = now + cron = croniter(expr, base_time) next_run = cron.get_next(datetime) return next_run.isoformat() @@ -664,7 +755,7 @@ def _atomic_write_epoch(path: Path) -> None: torn/truncated file. Best-effort: failures are swallowed by callers. """ ensure_dirs() - fd, tmp_path = tempfile.mkstemp(dir=str(CRON_DIR), suffix=".tmp", prefix=".hb_") + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=".hb_") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(str(time.time())) @@ -730,20 +821,21 @@ def get_ticker_success_age() -> Optional[float]: def load_jobs() -> List[Dict[str, Any]]: """Load all jobs from storage.""" + jobs_file = _current_cron_store().jobs_file ensure_dirs() - if not JOBS_FILE.exists(): + if not jobs_file.exists(): return [] _strict_retry = False # track whether we used the strict=False fallback try: - with open(JOBS_FILE, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8') as f: data = json.load(f) except json.JSONDecodeError: # Retry with strict=False to handle bare control chars in string values _strict_retry = True try: - with open(JOBS_FILE, 'r', encoding='utf-8') as f: + with open(jobs_file, 'r', encoding='utf-8') as f: data = json.loads(f.read(), strict=False) except Exception as e: logger.error("Failed to auto-repair jobs.json: %s", e) @@ -778,15 +870,16 @@ def load_jobs() -> List[Dict[str, Any]]: def _save_jobs_unlocked(jobs: List[Dict[str, Any]]): """Save all jobs to storage. Caller must hold _jobs_lock().""" + jobs_file = _current_cron_store().jobs_file ensure_dirs() - fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_') + fd, tmp_path = tempfile.mkstemp(dir=str(jobs_file.parent), suffix='.tmp', prefix='.jobs_') try: with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2) f.flush() os.fsync(f.fileno()) - atomic_replace(tmp_path, JOBS_FILE) - _secure_file(JOBS_FILE) + atomic_replace(tmp_path, jobs_file) + _secure_file(jobs_file) except BaseException: try: os.unlink(tmp_path) @@ -1452,7 +1545,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, "Job '%s' (%s) could not compute next_run_at; " "leaving enabled and marking state=error so the " "job is not silently disabled.", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), kind, ) else: @@ -1506,7 +1599,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.info( "Job '%s': dispatch limit reached (%d/%d) — removing", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), completed, times, ) @@ -1516,7 +1609,7 @@ def claim_dispatch(job_id: str) -> bool: save_jobs(jobs) logger.debug( "Job '%s': claimed dispatch %d/%d", - job.get("name", job["id"]), + job.get("name", job.get("id", "?")), repeat["completed"], times, ) @@ -1530,6 +1623,32 @@ def claim_dispatch(job_id: str) -> bool: return True +def heartbeat_run_claim(job_id: str) -> bool: + """Refresh a one-shot's ``run_claim`` timestamp while its run is alive. + + Called periodically from the scheduler's run monitor (#62002) so a + legitimately long run keeps its claim fresh: an expired claim then really + does mean "the claiming process died", and neither another process's tick + nor this process's own next tick will re-dispatch or stale-remove the job + while the run is in flight. mark_job_run() clears the claim on completion. + + Returns True if a claim was refreshed; False when the job or its claim is + gone (nothing to refresh — e.g. a manual run that never stamped one). + """ + with _jobs_lock(): + jobs = load_jobs() + for job in jobs: + if job.get("id") != job_id: + continue + claim = job.get("run_claim") + if not isinstance(claim, dict): + return False + claim["at"] = _hermes_now().isoformat() + save_jobs(jobs) + return True + return False + + def advance_next_run(job_id: str) -> bool: """Preemptively advance next_run_at for a recurring job before execution. @@ -1653,209 +1772,331 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: """Inner implementation of get_due_jobs(); must be called with _jobs_lock held.""" now = _hermes_now() raw_jobs = load_jobs() + needs_save = False + + # Repair id-less records BEFORE anything keys off ``job["id"]``. A direct + # jobs.json edit that bypassed add_job() can leave a record without an "id" + # (older writers used "job_id"). Every downstream site — the logging + # helpers and the ``for rj in raw_jobs: if rj["id"] == job["id"]`` + # persistence loops — indexes job["id"] eagerly, so a single malformed + # record raised KeyError mid-tick, aborting the whole scan before + # save_jobs() ran. That froze the entire profile's scheduler in a + # per-minute fast-forward loop (healthy jobs recomputed in memory, then + # discarded when the exception unwound). Recover the id from the drifted + # "job_id" key when present, else synthesize one, and persist. + for rj in raw_jobs: + if not rj.get("id"): + rj["id"] = rj.pop("job_id", None) or uuid.uuid4().hex[:12] + needs_save = True + jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] due = [] - needs_save = False + + # Normalize malformed "schedule" records (direct jobs.json edit, old writers, + # corruption, etc.). "schedule" must be a dict; a null/string/etc. value + # makes `schedule.get("kind")` or direct `schedule["kind"]` / ["expr"] / + # ["minutes"] later raise and abort the entire scan *before* save_jobs(). + # Healthy jobs then lose their fast-forwarded next_run_at (exactly the + # failure mode of the id-less job bug fixed above). Repair early at the + # source so the rest of the tick can proceed and persist progress for + # siblings. + for j in jobs: + if not isinstance(j.get("schedule"), dict): + j["schedule"] = {} + needs_save = True + for rj in raw_jobs: + if not isinstance(rj.get("schedule"), dict): + rj["schedule"] = {} + needs_save = True + + # Normalize malformed "next_run_at" records (direct jobs.json edit, + # corruption, migration, or buggy writer). If present but not a valid + # ISO string, datetime.fromisoformat(next_run) later raises and aborts + # the entire scan *before* save_jobs(). Healthy siblings then lose any + # fast-forwarded next_run_at (same class of bug as bad "id" or "schedule"). + # Strip the bad value so the existing "no next_run_at" recovery path + # recomputes a sane value and persists it for this job. + for j in jobs: + nr = j.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + j.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + j.pop("next_run_at", None) + needs_save = True + for rj in raw_jobs: + nr = rj.get("next_run_at") + if nr is not None: + if not isinstance(nr, str): + rj.pop("next_run_at", None) + needs_save = True + else: + try: + datetime.fromisoformat(nr) + except Exception: + rj.pop("next_run_at", None) + needs_save = True + + # Same treatment for last_run_at (used as base in recovery / compute_next_run). + for j in jobs: + lr = j.get("last_run_at") + if lr is not None and not isinstance(lr, str): + j.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + j.pop("last_run_at", None) + needs_save = True + for rj in raw_jobs: + lr = rj.get("last_run_at") + if lr is not None and not isinstance(lr, str): + rj.pop("last_run_at", None) + needs_save = True + elif isinstance(lr, str): + try: + datetime.fromisoformat(lr) + except Exception: + rj.pop("last_run_at", None) + needs_save = True + # Resolve the one-shot running-claim stale-recovery TTL once per scan # (derived from HERMES_CRON_TIMEOUT). See _oneshot_run_claim_ttl_seconds. _run_claim_ttl = _oneshot_run_claim_ttl_seconds() for job in jobs: - if not job.get("enabled", True): - continue - - # Cross-process running-claim guard (#59229): if another scheduler - # process already claimed this one-shot and its run is still in flight - # (claim younger than the TTL), skip it — do NOT re-dispatch. The - # claim is stamped just before we return the job as due (below) and - # cleared by mark_job_run() on completion. A claim older than the TTL - # is treated as stale (the claiming tick died mid-run) and allowed - # through so the job is recovered rather than wedged forever. - existing_claim = job.get("run_claim") - if existing_claim and job.get("schedule", {}).get("kind") == "once": - try: - claimed_at = _ensure_aware( - datetime.fromisoformat(existing_claim["at"]) - ) - # 0 <= age: a future-dated claim (clock/TZ skew across a - # restart) must be treated as stale, not eternally fresh, - # or the one-shot is skipped forever (#60703). - _age = (now - claimed_at).total_seconds() - if 0 <= _age < _run_claim_ttl: - continue # a fresh claim is held by an in-flight run - except (KeyError, ValueError, TypeError): - pass # malformed claim → fall through and (re)claim - - next_run = job.get("next_run_at") - if not next_run: - schedule = job.get("schedule", {}) - kind = schedule.get("kind") - - # One-shot jobs use a small grace window via the dedicated helper. - recovered_next = _recoverable_oneshot_run_at( - schedule, - now, - last_run_at=job.get("last_run_at"), - ) - recovery_kind = "one-shot" if recovered_next else None - - # Recurring jobs reach here only when something — typically a - # direct jobs.json edit that bypassed add_job() — left - # next_run_at unset. Without this branch, such jobs are - # silently skipped forever; recompute next_run_at from the - # schedule so they pick up at their next scheduled tick. - if not recovered_next and kind in {"cron", "interval"}: - recovered_next = compute_next_run(schedule, now.isoformat()) - if recovered_next: - recovery_kind = kind - - if not recovered_next: + # Per-job containment (structural guard): one malformed or + # unexpected job record must never abort the whole scan. The id / + # schedule / timestamp normalizations above repair the known shapes; + # this guard catches every FUTURE variant, degrading to "skip this + # job this tick" so healthy siblings still run and their recovered + # state still reaches save_jobs() below. + try: + if not job.get("enabled", True): continue - job["next_run_at"] = recovered_next - next_run = recovered_next - logger.info( - "Job '%s' had no next_run_at; recovering %s run at %s", - job.get("name", job["id"]), - recovery_kind, - recovered_next, - ) - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["next_run_at"] = recovered_next - needs_save = True - break - - raw_next_run_dt = datetime.fromisoformat(next_run) - schedule = job.get("schedule", {}) - kind = schedule.get("kind") - - next_run_dt = _ensure_aware(raw_next_run_dt) - # Migration repair: a cron job persists next_run_at as an absolute - # instant, but the cron expr describes local wall-clock intent. If the - # configured/system timezone changed after persistence, the stored - # instant's offset no longer matches now's, and its converted time can - # look due hours early (21:00+10 -> 13:00+02). When the stored *wall - # clock* is still in the future, recompute from the schedule so we fire - # at the intended local time instead of early-then-again. - # - # TRADE-OFF: this cannot distinguish a config/host TZ migration from a - # legitimate DST offset change. A DST boundary that satisfies all four - # conditions will recompute (and thus SKIP the pending occurrence, no - # catch-up) rather than fire it. Accepted: in the pure-migration case - # the recompute lands on the same wall-clock time later the same period, - # and DST-boundary collisions with a still-future stored wall clock are - # rare relative to the double-fire bug this prevents (#28934). - if ( - kind == "cron" - and next_run_dt <= now - and _timezone_offset_mismatch(raw_next_run_dt, now) - and _stored_wall_clock_is_future(raw_next_run_dt, now) - ): - new_next = compute_next_run(schedule, now.isoformat()) - if new_next: + # Cross-process running-claim guard (#59229): if another scheduler + # process already claimed this one-shot and its run is still in flight + # (claim younger than the TTL), skip it — do NOT re-dispatch. The + # claim is stamped just before we return the job as due (below) and + # cleared by mark_job_run() on completion. A claim older than the TTL + # is treated as stale (the claiming tick died mid-run) and allowed + # through so the job is recovered rather than wedged forever. + existing_claim = job.get("run_claim") + if existing_claim and job.get("schedule", {}).get("kind") == "once": + try: + claimed_at = _ensure_aware( + datetime.fromisoformat(existing_claim["at"]) + ) + # 0 <= age: a future-dated claim (clock/TZ skew across a + # restart) must be treated as stale, not eternally fresh, + # or the one-shot is skipped forever (#60703). + _age = (now - claimed_at).total_seconds() + if 0 <= _age < _run_claim_ttl: + continue # a fresh claim is held by an in-flight run + except (KeyError, ValueError, TypeError): + pass # malformed claim → fall through and (re)claim + + next_run = job.get("next_run_at") + if not next_run: + schedule = job.get("schedule", {}) + kind = schedule.get("kind") + + # One-shot jobs use a small grace window via the dedicated helper. + recovered_next = _recoverable_oneshot_run_at( + schedule, + now, + last_run_at=job.get("last_run_at"), + ) + recovery_kind = "one-shot" if recovered_next else None + + # Recurring jobs reach here only when something — typically a + # direct jobs.json edit that bypassed add_job() — left + # next_run_at unset. Without this branch, such jobs are + # silently skipped forever; recompute next_run_at from the + # schedule so they pick up at their next scheduled tick. + if not recovered_next and kind in {"cron", "interval"}: + recovered_next = compute_next_run(schedule, now.isoformat()) + if recovered_next: + recovery_kind = kind + + if not recovered_next: + continue + + job["next_run_at"] = recovered_next + next_run = recovered_next logger.info( - "Job '%s' next_run_at offset changed (%s -> %s). " - "Recomputing cron run to preserve local wall-clock intent: %s", - job.get("name", job["id"]), - raw_next_run_dt.utcoffset(), - now.utcoffset(), - new_next, + "Job '%s' had no next_run_at; recovering %s run at %s", + job.get("name", job.get("id", "?")), + recovery_kind, + recovered_next, ) for rj in raw_jobs: if rj["id"] == job["id"]: - rj["next_run_at"] = new_next + rj["next_run_at"] = recovered_next needs_save = True break - continue - if next_run_dt <= now: + raw_next_run_dt = datetime.fromisoformat(next_run) + schedule = job.get("schedule", {}) + kind = schedule.get("kind") - # For recurring jobs, check if the scheduled time is stale - # (gateway was down and missed the window). Fast-forward to - # the next future occurrence instead of firing a stale run. - grace = _compute_grace_seconds(schedule) - if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: - # Job is past its catch-up grace window — skip accumulated - # missed runs but still execute once now to avoid deferring - # indefinitely (e.g. a long-running job just finished). + next_run_dt = _ensure_aware(raw_next_run_dt) + # Migration repair: a cron job persists next_run_at as an absolute + # instant, but the cron expr describes local wall-clock intent. If the + # configured/system timezone changed after persistence, the stored + # instant's offset no longer matches now's, and its converted time can + # look due hours early (21:00+10 -> 13:00+02). When the stored *wall + # clock* is still in the future, recompute from the schedule so we fire + # at the intended local time instead of early-then-again. + # + # TRADE-OFF: this cannot distinguish a config/host TZ migration from a + # legitimate DST offset change. A DST boundary that satisfies all four + # conditions will recompute (and thus SKIP the pending occurrence, no + # catch-up) rather than fire it. Accepted: in the pure-migration case + # the recompute lands on the same wall-clock time later the same period, + # and DST-boundary collisions with a still-future stored wall clock are + # rare relative to the double-fire bug this prevents (#28934). + if ( + kind == "cron" + and next_run_dt <= now + and _timezone_offset_mismatch(raw_next_run_dt, now) + and _stored_wall_clock_is_future(raw_next_run_dt, now) + ): new_next = compute_next_run(schedule, now.isoformat()) if new_next: logger.info( - "Job '%s' missed its scheduled time (%s, grace=%ds). " - "Running now; next run provisionally set to: %s " - "(re-anchored on completion)", - job.get("name", job["id"]), - next_run, - grace, + "Job '%s' next_run_at offset changed (%s -> %s). " + "Recomputing cron run to preserve local wall-clock intent: %s", + job.get("name", job.get("id", "?")), + raw_next_run_dt.utcoffset(), + now.utcoffset(), new_next, ) - # Persist the fast-forward to storage now (skip accumulated - # slots). In the built-in ticker path this is shortly - # overwritten by advance_next_run + mark_job_run, but it is - # NOT redundant: it (a) protects the crash window between - # here and mark_job_run, and (b) covers the external - # fire_due provider path, which does not call - # advance_next_run. mark_job_run re-anchors next_run_at off - # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: rj["next_run_at"] = new_next needs_save = True break - # Fall through to due.append(job) — execute once now - - # One-shot dispatch-limit guard (issue #38758): a finite one-shot - # claimed via claim_dispatch() but whose tick died before - # mark_job_run could remove it will have completed >= times while - # still looking due (last_run_at was never written, so the - # recovery helper re-armed it). Remove it instead of re-firing. - if kind == "once": - repeat = job.get("repeat") - if repeat: - times = repeat.get("times") - completed = repeat.get("completed", 0) - if times is not None and times > 0 and completed >= times: + continue + + if next_run_dt <= now: + + # For recurring jobs, check if the scheduled time is stale + # (gateway was down and missed the window). Fast-forward to + # the next future occurrence instead of firing a stale run. + grace = _compute_grace_seconds(schedule) + if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: + # Job is past its catch-up grace window — skip accumulated + # missed runs but still execute once now to avoid deferring + # indefinitely (e.g. a long-running job just finished). + new_next = compute_next_run(schedule, now.isoformat()) + if new_next: logger.info( - "Job '%s': one-shot dispatch limit reached (%d/%d) " - "— removing stale due entry", - job.get("name", job["id"]), - completed, - times, + "Job '%s' missed its scheduled time (%s, grace=%ds). " + "Running now; next run provisionally set to: %s " + "(re-anchored on completion)", + job.get("name", job.get("id", "?")), + next_run, + grace, + new_next, ) + # Persist the fast-forward to storage now (skip accumulated + # slots). In the built-in ticker path this is shortly + # overwritten by advance_next_run + mark_job_run, but it is + # NOT redundant: it (a) protects the crash window between + # here and mark_job_run, and (b) covers the external + # fire_due provider path, which does not call + # advance_next_run. mark_job_run re-anchors next_run_at off + # the actual completion time, so this value is provisional. for rj in raw_jobs: if rj["id"] == job["id"]: - raw_jobs.remove(rj) + rj["next_run_at"] = new_next needs_save = True break - continue - - # Durably claim a one-shot for the DURATION of its run before - # returning it as due, so a second scheduler process (gateway + - # desktop both run in-process 60s tickers on one HERMES_HOME) - # cannot re-dispatch it while the first run is still in flight - # (#59229). A plain one-shot's due-state is not resolved until - # mark_job_run() completes it minutes later, so advancing - # next_run_at by a fixed window is not enough — a job that outlives - # one tick (e.g. a 2.5-min research prompt) would simply re-fire on - # the next tick after the window. Instead we stamp a run_claim under - # the same lock get_due_jobs already holds; the other process reads - # a fresh claim on its next tick and skips (handled at the top of - # this loop). mark_job_run() clears the claim on completion. The TTL - # is only a safety valve: a claiming tick that DIES mid-run leaves a - # stale claim that expires after the resolved run-claim TTL - # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), - # so the job is re-dispatched rather than wedged forever. - if kind == "once": - claim = {"at": now.isoformat(), "by": _machine_id()} - job["run_claim"] = claim - for rj in raw_jobs: - if rj["id"] == job["id"]: - rj["run_claim"] = claim - needs_save = True - break + # Fall through to due.append(job) — execute once now + + # One-shot dispatch-limit guard (issue #38758): a finite one-shot + # claimed via claim_dispatch() but whose tick died before + # mark_job_run could remove it will have completed >= times while + # still looking due (last_run_at was never written, so the + # recovery helper re-armed it). Remove it instead of re-firing. + if kind == "once": + repeat = job.get("repeat") + if repeat: + times = repeat.get("times") + completed = repeat.get("completed", 0) + if times is not None and times > 0 and completed >= times: + # A live run must never have its job record deleted + # underneath it (#62002): a run that outlives the + # run_claim TTL (stream stall, laptop asleep + # mid-run) satisfies the same completed >= times + + # expired-claim condition as a dead tick, but + # mark_job_run() still needs the record to land + # last_run_at / last_status / last_delivery_error. + # If this process is still running the job, it is + # slow, not stale — keep the entry and skip. + if _job_running_in_this_process(job.get("id", "")): + logger.info( + "Job '%s': dispatch limit reached (%d/%d) " + "but its run is still in flight in this " + "process — keeping entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + continue + logger.info( + "Job '%s': one-shot dispatch limit reached (%d/%d) " + "— removing stale due entry", + job.get("name", job.get("id", "?")), + completed, + times, + ) + for rj in raw_jobs: + if rj["id"] == job["id"]: + raw_jobs.remove(rj) + needs_save = True + break + continue + + # Durably claim a one-shot for the DURATION of its run before + # returning it as due, so a second scheduler process (gateway + + # desktop both run in-process 60s tickers on one HERMES_HOME) + # cannot re-dispatch it while the first run is still in flight + # (#59229). A plain one-shot's due-state is not resolved until + # mark_job_run() completes it minutes later, so advancing + # next_run_at by a fixed window is not enough — a job that outlives + # one tick (e.g. a 2.5-min research prompt) would simply re-fire on + # the next tick after the window. Instead we stamp a run_claim under + # the same lock get_due_jobs already holds; the other process reads + # a fresh claim on its next tick and skips (handled at the top of + # this loop). mark_job_run() clears the claim on completion. The TTL + # is only a safety valve: a claiming tick that DIES mid-run leaves a + # stale claim that expires after the resolved run-claim TTL + # (_oneshot_run_claim_ttl_seconds, derived from HERMES_CRON_TIMEOUT), + # so the job is re-dispatched rather than wedged forever. + if kind == "once": + claim = {"at": now.isoformat(), "by": _machine_id()} + job["run_claim"] = claim + for rj in raw_jobs: + if rj["id"] == job["id"]: + rj["run_claim"] = claim + needs_save = True + break - due.append(job) + due.append(job) + except Exception: + logger.exception( + "Skipping malformed cron job %r during due scan", + job.get("name") or job.get("id") or "?", + ) + continue if needs_save: save_jobs(raw_jobs) diff --git a/cron/scheduler.py b/cron/scheduler.py index 9e645d3728f3..d75398d19451 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -20,6 +20,7 @@ import subprocess import sys import threading +import time # fcntl is Unix-only; on Windows use msvcrt for file locking try: @@ -237,7 +238,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: "QQBOT_HOME_CHANNEL": "QQ_HOME_CHANNEL", } -from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch +from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch, heartbeat_run_claim # Sentinel: when a cron agent has nothing new to report, it can start its # response with this marker to suppress delivery. Output is still saved @@ -2213,7 +2214,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: # Inject output from referenced cron jobs as context. context_from = job.get("context_from") if context_from: - from cron.jobs import OUTPUT_DIR + from cron.jobs import get_cron_output_dir + output_dir = get_cron_output_dir() if isinstance(context_from, str): context_from = [context_from] for source_job_id in context_from: @@ -2228,7 +2230,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str: ) continue try: - job_output_dir = OUTPUT_DIR / source_job_id + job_output_dir = output_dir / source_job_id if not job_output_dir.exists(): continue # silent skip — no output yet output_files = sorted( @@ -3096,6 +3098,35 @@ def run_job( _cron_timeout = 600.0 _cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None _POLL_INTERVAL = 5.0 + # Keep the one-shot run_claim fresh while the run is alive (#62002): + # the claim TTL is a dead-owner detector, but without a heartbeat a + # run that legitimately outlives it (stream stall, laptop asleep + # mid-run) is indistinguishable from a dead tick — another process + # re-dispatches it and get_due_jobs stale-removes the job record out + # from under the live run. Refreshing the claim from this monitor + # keeps "expired claim" meaning "owner died". + _job_schedule = job.get("schedule") + _is_oneshot = ( + isinstance(_job_schedule, dict) and _job_schedule.get("kind") == "once" + ) + _CLAIM_HEARTBEAT_SECONDS = 60.0 + _last_claim_heartbeat = time.monotonic() + + def _heartbeat_run_claim_if_due(): + nonlocal _last_claim_heartbeat + if not _is_oneshot: + return + _mono = time.monotonic() + if _mono - _last_claim_heartbeat < _CLAIM_HEARTBEAT_SECONDS: + return + _last_claim_heartbeat = _mono + try: + heartbeat_run_claim(job_id) + except Exception: + logger.debug( + "Job '%s': run_claim heartbeat failed", job_name, exc_info=True + ) + _cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) # Preserve scheduler-scoped ContextVar state (for example skill-declared # env passthrough registrations) when the cron run hops into the worker @@ -3105,8 +3136,20 @@ def run_job( _inactivity_timeout = False try: if _cron_inactivity_limit is None: - # Unlimited — just wait for the result. - result = _cron_future.result() + # Unlimited — no inactivity watchdog, but a one-shot still + # needs its run_claim heartbeat, so poll instead of blocking. + if _is_oneshot: + result = None + while True: + done, _ = concurrent.futures.wait( + {_cron_future}, timeout=_POLL_INTERVAL, + ) + if done: + result = _cron_future.result() + break + _heartbeat_run_claim_if_due() + else: + result = _cron_future.result() else: result = None while True: @@ -3116,6 +3159,7 @@ def run_job( if done: result = _cron_future.result() break + _heartbeat_run_claim_if_due() # Agent still running — check inactivity. _idle_secs = 0.0 if hasattr(agent, "get_activity_summary"): diff --git a/gateway/config.py b/gateway/config.py index 8c467196e3b5..87f1c7014788 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]: return parsed +def _coerce_dict(value: Any) -> Dict[str, Any]: + """Return *value* when it is a mapping, otherwise an empty dict.""" + return value if isinstance(value, dict) else {} + + def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: """Normalize unauthorized DM behavior to a supported value.""" if isinstance(value, str): @@ -383,6 +388,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + data = _coerce_dict(data) # Handle both missing keys and explicit null values (YAML null → None) mode = data.get("mode") at_hour = data.get("at_hour") @@ -492,24 +498,26 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + data = _coerce_dict(data) home_channel = None - if "home_channel" in data: + if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` # works without needing a separate platforms: block. + extra = _coerce_dict(data.get("extra", {})) _grn = data.get("gateway_restart_notification") if _grn is None: - _grn = data.get("extra", {}).get("gateway_restart_notification") + _grn = extra.get("gateway_restart_notification") # typing_indicator mirrors gateway_restart_notification: it may arrive # top-level or bridged into extra by the shared-key loop in # load_gateway_config(), so check both. _typing = data.get("typing_indicator") if _typing is None: - _typing = data.get("extra", {}).get("typing_indicator") + _typing = extra.get("typing_indicator") channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} @@ -527,7 +535,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), channel_overrides=channel_overrides, - extra=data.get("extra", {}), + extra=extra, ) @@ -586,7 +594,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": - if not data: + if not isinstance(data, dict) or not data: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), @@ -823,8 +831,12 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + data = _coerce_dict(data) platforms = {} - for platform_name, platform_data in data.get("platforms", {}).items(): + platforms_data = _coerce_dict(data.get("platforms", {})) + for platform_name, platform_data in platforms_data.items(): + if not isinstance(platform_data, dict): + continue try: platform = Platform(platform_name) platforms[platform] = PlatformConfig.from_dict(platform_data) @@ -832,11 +844,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": pass # Skip unknown platforms reset_by_type = {} - for type_name, policy_data in data.get("reset_by_type", {}).items(): + for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_platform = {} - for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) @@ -1027,6 +1039,8 @@ def load_gateway_config() -> GatewayConfig: if "stt_echo_transcripts" in yaml_cfg: gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"] + gateway_cfg = yaml_cfg.get("gateway") + if "group_sessions_per_user" in yaml_cfg: gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] @@ -1054,7 +1068,11 @@ def load_gateway_config() -> GatewayConfig: if not isinstance(streaming_cfg, dict): # Fall back to nested gateway.streaming written by # ``hermes config set gateway.streaming.*`` - streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming") + streaming_cfg = ( + gateway_cfg.get("streaming") + if isinstance(gateway_cfg, dict) + else None + ) if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg @@ -1087,7 +1105,6 @@ def load_gateway_config() -> GatewayConfig: # ``gateway.platforms`` are loaded the same way as top-level # ``platforms``. Merge nested first so top-level config keeps # precedence, matching the existing gateway.streaming fallback. - gateway_cfg = yaml_cfg.get("gateway") gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None platforms_data = gw_data.setdefault("platforms", {}) if not isinstance(platforms_data, dict): diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 2639ab52fdd3..865a38063a1d 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -278,8 +278,16 @@ def enforces_own_access_policy(self) -> bool: # Connection lifecycle # ------------------------------------------------------------------ - async def connect(self) -> bool: - """Authenticate, obtain gateway URL, and open the WebSocket.""" + async def connect(self, *, is_reconnect: bool = False) -> bool: + """ + Authenticate, obtain gateway URL, and open the WebSocket. + + Args: + is_reconnect: False on a cold first boot; True when the + reconnect watcher is re-establishing this platform after + an outage. QQBot has no server-side update queue so this + flag is accepted for interface conformance only. + """ if not AIOHTTP_AVAILABLE: message = "QQ startup failed: aiohttp not installed" self._set_fatal_error("qq_missing_dependency", message, retryable=True) diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 0c64aaedeb5a..87326c4e4a01 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -36,7 +36,8 @@ def relay_url() -> Optional[str]: from gateway.run import _load_gateway_config # late import to avoid cycle cfg = _load_gateway_config() - url = (cfg.get("gateway") or {}).get("relay_url", "").strip() + url = (cfg.get("gateway") or {}).get("relay_url") + url = (url or "").strip() if url: return url.rstrip("/") except Exception: # noqa: BLE001 - config absence/parse must never crash registration diff --git a/gateway/run.py b/gateway/run.py index 54f38d1bb795..ccfa8e92c143 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1744,6 +1744,7 @@ def _profile_runtime_scope(profile_home: "Path"): load_gateway_config, ) from gateway.session import ( + AsyncSessionStore, SessionStore, SessionSource, SessionContext, @@ -2853,6 +2854,10 @@ def __init__(self, config: Optional[GatewayConfig] = None): key, max_active_age=_bg_max_age_seconds, ), ) + # One enforced loop-side boundary for the synchronous SessionStore. + # Sync helpers keep using ``session_store`` directly; async gateway + # handlers call this facade and await every operation. + self._async_session_store = AsyncSessionStore(self.session_store) self.delivery_router = DeliveryRouter(self.config) self._running = False self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None @@ -5174,7 +5179,7 @@ def _agent_has_active_subagents(running_agent: Any) -> bool: except Exception: return False - def _session_has_compression_in_flight(self, session_key: str) -> bool: + async def _session_has_compression_in_flight(self, session_key: str) -> bool: """Return True when a compression lock is held for this session's id. Context compression is interrupt-protected (#23975) but gateway @@ -5182,28 +5187,43 @@ def _session_has_compression_in_flight(self, session_key: str) -> bool: the pre-rotation parent while compression is mid-flight, producing orphaned compression siblings (#56391). Callers demote interrupt to queue when this returns True. + + Both blocking sources — the ``session_store`` lock + JSON load, and the + SQLite ``get_compression_lock_holder`` SELECT — are offloaded to a + worker thread so a large state.db never freezes the event loop (#5). """ session_store = getattr(self, "session_store", None) if not session_key or session_store is None: return False try: - with session_store._lock: # noqa: SLF001 — snapshot entry under lock - session_store._ensure_loaded_locked() # noqa: SLF001 - entry = session_store._entries.get(session_key) # noqa: SLF001 - session_id = getattr(entry, "session_id", None) if entry is not None else None - if not session_id: - return False + session_id = await asyncio.to_thread( + self._lookup_session_id_under_store_lock, session_store, session_key + ) except Exception: return False + if not session_id: + return False session_db = getattr(self, "_session_db", None) if session_db is None: return False - db = getattr(session_db, "_db", session_db) + raw_db = getattr(session_db, "_db", session_db) try: - return bool(db.get_compression_lock_holder(str(session_id))) + holder = await asyncio.to_thread( + raw_db.get_compression_lock_holder, str(session_id) + ) + return bool(holder) except Exception: return False + @staticmethod + def _lookup_session_id_under_store_lock(session_store, session_key: str): + """Sync helper run in the thread pool: read session_id under the store lock.""" + # noqa: SLF001 — intentional private access; runs off the event loop. + with session_store._lock: # noqa: SLF001 + session_store._ensure_loaded_locked() # noqa: SLF001 + entry = session_store._entries.get(session_key) # noqa: SLF001 + return getattr(entry, "session_id", None) if entry is not None else None + # Hard cap on per-session pending follow-ups for busy_input_mode=queue # (and the draining/steer-fallback/subagent-demotion paths that share # this entry point). Without a cap, a stuck agent + a rapid-fire user @@ -5426,7 +5446,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session effective_mode = "queue" demoted_for_compression = ( effective_mode == "interrupt" - and self._session_has_compression_in_flight(session_key) + and await self._session_has_compression_in_flight(session_key) ) if demoted_for_compression: logger.info( @@ -5715,7 +5735,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None: source = None try: if getattr(self, "session_store", None) is not None: - self.session_store._ensure_loaded() + await self.async_session_store._ensure_loaded() entry = self.session_store._entries.get(session_key) source = getattr(entry, "origin", None) if entry else None except Exception as e: @@ -6985,7 +7005,7 @@ async def start(self) -> bool: pass else: try: - suspended = self.session_store.suspend_recently_active() + suspended = await self.async_session_store.suspend_recently_active() if suspended: logger.info("Marked %d in-flight session(s) as resumable from previous run", suspended) except Exception as e: @@ -7546,13 +7566,13 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None: # Make sure there's an entry in the session_store for this key. If # the home channel has never been used, get_or_create_session # creates one; switch_session then re-points it. - self.session_store.get_or_create_session(dest_source) + await self.async_session_store.get_or_create_session(dest_source) # Re-bind the destination key to the CLI session_id. switch_session # ends the prior session in SQLite and reopens the CLI session under # the new key. The CLI's transcript becomes the active one for the # gateway from this moment on. - switched = self.session_store.switch_session(session_key, cli_session_id) + switched = await self.async_session_store.switch_session(session_key, cli_session_id) if switched is None: raise RuntimeError( f"could not switch session key {session_key} → {cli_session_id}" @@ -7629,13 +7649,13 @@ async def _session_expiry_watcher(self, interval: int = 300): _MAX_FINALIZE_RETRIES = 3 while self._running: try: - self.session_store._ensure_loaded() + await self.async_session_store._ensure_loaded() # Collect expired sessions first, then log a single summary. _expired_entries = [] for key, entry in list(self.session_store._entries.items()): if entry.expiry_finalized: continue - if not self.session_store._is_session_expired(entry): + if not await self.async_session_store._is_session_expired(entry): continue _expired_entries.append((key, entry)) @@ -7719,7 +7739,7 @@ async def _session_expiry_watcher(self, interval: int = 300): # state.db (single write-path, #9006) — also drops # the persisted /model override, since finalization # is a conversation boundary. - self.session_store.set_expiry_finalized(entry) + await self.async_session_store.set_expiry_finalized(entry) logger.debug( "Session expiry finalized for %s", entry.session_id, @@ -7734,7 +7754,7 @@ async def _session_expiry_watcher(self, interval: int = 300): "Marking as finalized to prevent infinite retry loop.", failures, entry.session_id, e, ) - self.session_store.set_expiry_finalized( + await self.async_session_store.set_expiry_finalized( entry, clear_model_override=False ) _finalize_failures.pop(entry.session_id, None) @@ -7787,7 +7807,7 @@ async def _session_expiry_watcher(self, interval: int = 300): getattr(self.config, "session_store_max_age_days", 0) or 0 ) if _max_age > 0: - _pruned = self.session_store.prune_old_entries(_max_age) + _pruned = await self.async_session_store.prune_old_entries(_max_age) if _pruned: logger.info( "SessionStore prune: dropped %d stale entries", @@ -8121,7 +8141,7 @@ def _phase_elapsed() -> float: if _agent is _AGENT_PENDING_SENTINEL: continue try: - self.session_store.mark_resume_pending( + await self.async_session_store.mark_resume_pending( _sk, "restart_timeout" if self._restart_requested else "shutdown_timeout", ) @@ -8152,7 +8172,7 @@ def _phase_elapsed() -> float: for _sk in _pre_drain_keys: if _sk not in self._running_agents: try: - self.session_store.clear_resume_pending(_sk) + await self.async_session_store.clear_resume_pending(_sk) except Exception as _e: logger.debug( "clear_resume_pending after drain failed for %s: %s", @@ -8195,7 +8215,7 @@ def _phase_elapsed() -> float: if _agent is _AGENT_PENDING_SENTINEL: continue try: - self.session_store.mark_resume_pending(_sk, _resume_reason) + await self.async_session_store.mark_resume_pending(_sk, _resume_reason) except Exception as _e: logger.debug( "mark_resume_pending failed for %s: %s", @@ -9568,7 +9588,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # the id out from under it, forking orphaned compression # siblings. Demote to queue semantics so the follow-up waits # for the in-flight compression + rotation to land. - if self._session_has_compression_in_flight(_quick_key): + if await self._session_has_compression_in_flight(_quick_key): logger.info( "PRIORITY interrupt demoted to queue for session %s " "because context compression is in flight (#56391)", @@ -9609,7 +9629,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if isinstance(quick_commands, dict) and command in quick_commands: qcmd = quick_commands[command] if qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() + target = (qcmd.get("target") or "").strip() if target: target = target if target.startswith("/") else f"/{target}" target_command = target.lstrip("/") @@ -10021,7 +10041,7 @@ async def _do_undo(): else: return f"Quick command '/{command}' has no command defined." elif qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() + target = (qcmd.get("target") or "").strip() if target: target = target if target.startswith("/") else f"/{target}" target_command = target.lstrip("/") @@ -10275,7 +10295,7 @@ async def _do_undo(): # on error. Let the user drive the next turn. if _final_text.strip(): try: - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) except Exception: session_entry = None if session_entry is not None: @@ -10656,6 +10676,15 @@ def _cache_session_source(self, session_key: str, source) -> None: except Exception: pass + @property + def async_session_store(self) -> AsyncSessionStore: + """Return the single async facade for this runner's SessionStore.""" + facade = getattr(self, "_async_session_store", None) + if facade is None or facade._store is not self.session_store: + facade = AsyncSessionStore(self.session_store) + self._async_session_store = facade + return facade + def _get_cached_session_source(self, session_key: str): if not session_key: return None @@ -10699,7 +10728,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g except Exception: pass - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key pinned_session_id = str( (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" @@ -10731,7 +10760,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g ) return prior_session_id = session_entry.session_id - switched = self.session_store.switch_session(session_key, pinned_session_id) + switched = await self.async_session_store.switch_session(session_key, pinned_session_id) if switched is not None: session_entry = switched logger.info( @@ -10781,7 +10810,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # lane session is ended cleanly. Mutating session_entry in # place here created a split-brain state where the JSON # index pointed at one id but code downstream used another. - switched = self.session_store.switch_session(session_key, bound_session_id) + switched = await self.async_session_store.switch_session(session_key, bound_session_id) if switched is not None: session_entry = switched # If the stored binding pointed at a parent, rewrite it to the @@ -10969,7 +10998,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e) # Load conversation history from transcript - history = self.session_store.load_transcript(session_entry.session_id) + history = await self.async_session_store.load_transcript(session_entry.session_id) # ----------------------------------------------------------------- # Session hygiene: auto-compress pathologically large transcripts @@ -11232,7 +11261,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g ) if _hyg_rotated: session_entry.session_id = _hyg_new_sid - self.session_store._save() + await self.async_session_store._save() await asyncio.to_thread( self._sync_telegram_topic_binding, source, session_entry, @@ -11240,7 +11269,17 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g ) # Only rewrite the transcript when rotation produced - # a NEW session id OR in-place compaction succeeded. + # a NEW session id. In-place compaction does NOT + # need a rewrite: archive_and_compact() has already + # soft-archived the previous active rows and inserted + # the compacted messages as the new active set inside + # _compress_context(). Calling rewrite_transcript() + # after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs + # ALL rows — including the archived turns that + # archive_and_compact() deliberately preserved + # (silent data loss, #61145). + # # The danger this guards against (mirrors the # /compress fix #44794/#39704): if _compress_context # returns a summary but neither rotates nor completes @@ -11249,8 +11288,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # rewrite_transcript() would DELETE the original # messages and replace them with only the compressed # summary (permanent data loss, #21301). - if _hyg_rotated or _hyg_in_place: - self.session_store.rewrite_transcript( + if _hyg_rotated: + await self.async_session_store.rewrite_transcript( session_entry.session_id, _compressed ) # Reset stored token count — transcript rewritten @@ -11260,6 +11299,16 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _new_tokens = estimate_messages_tokens_rough( _compressed ) + elif _hyg_in_place: + # archive_and_compact() already persisted the + # compacted transcript inside _compress_context. + # Reset counts to match the new active set. + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) else: # No rewrite happened — transcript preserved # unchanged, so the post-compression counts equal @@ -11357,7 +11406,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g ) # First-message onboarding -- only on the very first interaction ever - if not history and not self.session_store.has_any_sessions(): + if not history and not await self.async_session_store.has_any_sessions(): # Default first-contact note: a brief self-introduction. _intro_note = ( "\n\n[System note: This is the user's very first message ever. " @@ -11601,7 +11650,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if session_key and _should_clear_resume_pending_after_turn(agent_result): self._clear_restart_failure_count(session_key) try: - self.session_store.clear_resume_pending(session_key) + await self.async_session_store.clear_resume_pending(session_key) except Exception as _e: logger.debug( "clear_resume_pending failed for %s: %s", @@ -11623,8 +11672,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id: if session_entry.session_id == _run_start_session_id: session_entry.session_id = agent_result["session_id"] - self.session_store._save() - self.session_store._record_gateway_session_peer( + await self.async_session_store._save() + await self.async_session_store._record_gateway_session_peer( session_entry.session_id, session_key, source, @@ -11822,7 +11871,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g "Auto-resetting session %s after compression exhaustion.", session_entry.session_id, ) - new_entry = self.session_store.reset_session(session_key) + new_entry = await self.async_session_store.reset_session(session_key) self._evict_cached_agent(session_key) self._session_model_overrides.pop(session_key, None) self._set_session_reasoning_override(session_key, None) @@ -11866,7 +11915,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g pass # Skip all transcript writes — don't grow a broken session elif not history: tool_defs = agent_result.get("tools", []) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, { "role": "session_meta", @@ -11920,7 +11969,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # after transient failures). #47237 _skip_persist = ( event.message_id - and self.session_store.has_platform_message_id( + and await self.async_session_store.has_platform_message_id( session_entry.session_id, str(event.message_id) ) ) @@ -11931,7 +11980,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g event.message_id, session_entry.session_id, ) else: - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, skip_db=agent_persisted, @@ -11957,13 +12006,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g } if event.message_id: _user_entry["message_id"] = str(event.message_id) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, skip_db=agent_persisted, ) if response: - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, {"role": "assistant", "content": response, "timestamp": ts}, skip_db=agent_persisted, @@ -11988,7 +12037,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g ): entry["message_id"] = str(event.message_id) _user_msg_id_attached = True - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, entry, skip_db=agent_persisted, ) @@ -11996,7 +12045,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Token counts and model are now persisted by the agent directly. # Keep only last_prompt_tokens here for context-window tracking and # compression decisions. - self.session_store.update_session( + await self.async_session_store.update_session( session_entry.session_key, last_prompt_tokens=agent_result.get("last_prompt_tokens", 0), ) @@ -12096,7 +12145,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if 'message_text' in locals() and message_text is not None and session_entry is not None: _already_persisted = False try: - _recent_transcript = self.session_store.load_transcript(session_entry.session_id) + _recent_transcript = await self.async_session_store.load_transcript(session_entry.session_id) except Exception: _recent_transcript = [] for _msg in reversed(_recent_transcript[-10:]): @@ -12124,7 +12173,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g } if getattr(event, "message_id", None): _user_entry["message_id"] = str(event.message_id) - self.session_store.append_to_transcript( + await self.async_session_store.append_to_transcript( session_entry.session_id, _user_entry, ) @@ -12579,7 +12628,7 @@ def _goal_max_turns_from_config(self) -> int: except Exception: return 20 - def _get_goal_manager_for_event(self, event: "MessageEvent"): + async def _get_goal_manager_for_event(self, event: "MessageEvent"): """Return a GoalManager bound to the session for this gateway event. Returns ``(manager, session_entry)`` or ``(None, None)`` if the @@ -12591,7 +12640,7 @@ def _get_goal_manager_for_event(self, event: "MessageEvent"): logger.debug("goal manager unavailable: %s", exc) return None, None try: - session_entry = self.session_store.get_or_create_session(event.source) + session_entry = await self.async_session_store.get_or_create_session(event.source) except Exception as exc: logger.debug("goal manager: session lookup failed: %s", exc) return None, None @@ -14034,8 +14083,8 @@ async def _execute_mcp_reload(self, event: MessageEvent) -> str: "content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", } try: - session_entry = self.session_store.get_or_create_session(event.source) - self.session_store.append_to_transcript( + session_entry = await self.async_session_store.get_or_create_session(event.source) + await self.async_session_store.append_to_transcript( session_entry.session_id, reload_msg ) except Exception: @@ -16503,7 +16552,8 @@ def _get_proxy_url(self) -> Optional[str]: if url: return url.rstrip("/") cfg = _load_gateway_config() - url = (cfg.get("gateway") or {}).get("proxy_url", "").strip() + url = (cfg.get("gateway") or {}).get("proxy_url") + url = (url or "").strip() if url: return url.rstrip("/") return None diff --git a/gateway/session.py b/gateway/session.py index fb2e08f4299e..fea3ba4a3c0d 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -8,6 +8,7 @@ - Dynamic system prompt injection (agent knows its context) """ +import asyncio import hashlib import logging import os @@ -958,6 +959,30 @@ def build_session_key( return ":".join(key_parts) +class _SessionFlight: + def __init__(self) -> None: + self.event = threading.Event() + self.result: Optional["SessionEntry"] = None + self.error: Optional[BaseException] = None + + +class AsyncSessionStore: + """Async boundary for the synchronous, thread-safe SessionStore.""" + + def __init__(self, store: "SessionStore") -> None: + self._store = store + + def __getattr__(self, name: str): + attr = getattr(self._store, name) + if not callable(attr): + return attr + + async def _offloaded(*args, **kwargs) -> Any: + return await asyncio.to_thread(attr, *args, **kwargs) + + return _offloaded + + class SessionStore: """ Manages session storage and retrieval. @@ -973,6 +998,14 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig, self._entries: Dict[str, SessionEntry] = {} self._loaded = False self._lock = threading.Lock() + # Serialize whole-index persistence without holding ``_lock`` across + # SQLite / fsync. Each writer snapshots the latest state only after + # acquiring this lock, preventing stale delayed writes. + self._save_lock = threading.Lock() + self._routing_generation = 0 + self._persisted_routing_generation = 0 + self._inflight_lock = threading.Lock() + self._inflight_sessions: Dict[str, _SessionFlight] = {} self._has_active_processes_fn = has_active_processes_fn # Whether to keep writing the legacy sessions.json mirror alongside # the primary gateway_routing table in state.db. Default True for @@ -1179,40 +1212,45 @@ def _prune_stale_sessions_locked(self) -> None: self._save() def _save(self) -> None: - """Persist the routing index (session key -> ID mapping). - - state.db's ``gateway_routing`` table is the primary store (#9006 - follow-up): the whole index is replaced atomically in one SQLite - transaction, mirroring the previous full-file JSON rewrite semantics. - - sessions.json is additionally written for backward compatibility - (external tooling, downgrade safety) unless the user disables it via - ``gateway.write_sessions_json: false`` in config.yaml. - """ - data = {key: entry.to_dict() for key, entry in self._entries.items()} - - # Primary: durable SQLite routing table. - db_saved = False - _db = getattr(self, "_db", None) - if _db: - replacer = getattr(_db, "replace_gateway_routing_entries", None) - if callable(replacer): - try: - replacer( - {k: json.dumps(v) for k, v in data.items()}, - scope=self._routing_scope(), - ) - db_saved = True - except Exception as exc: - logger.warning( - "gateway.session: state.db routing save failed: %s", exc - ) + """Persist the routing index while the caller holds ``_lock``.""" + data, generation = self._snapshot_routing_locked() + self._persist_routing_data(data, generation) + + def _snapshot_routing_locked(self) -> tuple[Dict[str, Any], int]: + """Capture immutable routing data and a monotonic generation.""" + self._routing_generation = getattr(self, "_routing_generation", 0) + 1 + return ( + {key: entry.to_dict() for key, entry in self._entries.items()}, + self._routing_generation, + ) - # Legacy mirror: sessions.json. Kept on by default for compat; when - # disabled we still fall back to it if the DB write failed, so the - # index is never lost entirely. - if getattr(self, "_write_sessions_json", True) or not db_saved: - self._save_sessions_json(data) + def _persist_routing_data(self, data: Dict[str, Any], generation: int) -> None: + """Serialize all whole-index writers through one durable write lock.""" + save_lock = getattr(self, "_save_lock", None) + if save_lock is None: + save_lock = threading.Lock() + self._save_lock = save_lock + with save_lock: + if generation <= getattr(self, "_persisted_routing_generation", 0): + return + db_saved = False + _db = getattr(self, "_db", None) + if _db: + replacer = getattr(_db, "replace_gateway_routing_entries", None) + if callable(replacer): + try: + replacer( + {k: json.dumps(v) for k, v in data.items()}, + scope=self._routing_scope(), + ) + db_saved = True + except Exception as exc: + logger.warning( + "gateway.session: state.db routing save failed: %s", exc + ) + if getattr(self, "_write_sessions_json", True) or not db_saved: + self._save_sessions_json(data) + self._persisted_routing_generation = generation def _save_sessions_json(self, data: Dict[str, Any]) -> None: """Write the legacy sessions.json mirror of the routing index.""" @@ -1254,6 +1292,11 @@ def _save_sessions_json(self, data: Dict[str, Any]) -> None: logger.debug("Could not remove temp file %s: %s", tmp_path, e) raise + def _save_entries(self) -> None: + """Snapshot latest state under ``_lock`` and persist after releasing it.""" + with self._lock: + data, generation = self._snapshot_routing_locked() + self._persist_routing_data(data, generation) def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Optional[str]: """Return the profile namespace for session keys, or None when off. @@ -1395,6 +1438,51 @@ def _recover_session_from_db( now=now, ) + def _query_recoverable_session(self, *, session_key, source, now): + """DB-only half of _recover_session_from_db (no lock needed). + + Returns a SessionEntry or None. Caller assigns _entries[key] under lock. + """ + if not self._db: + return None + finder = getattr(self._db, "find_latest_gateway_session_for_peer", None) + if not callable(finder): + return None + try: + recovered = finder( + source=source.platform.value, + user_id=source.user_id, + session_key=session_key, + chat_id=source.chat_id, + chat_type=source.chat_type, + thread_id=source.thread_id, + ) + except Exception as exc: + logger.debug("Gateway session DB recovery failed for %s: %s", + session_key, exc) + return None + if not isinstance(recovered, dict): + return None + if not self._recovered_row_allowed_for_active_profile( + requested_session_key=session_key, + recovered=recovered, + ): + logger.warning( + "Gateway session DB recovery ignored %s for %s because " + "multiplex_profiles is disabled and the row belongs to a " + "different profile", + recovered.get("session_key"), + session_key, + ) + return None + try: + self._db.reopen_session(str(recovered["id"])) + except Exception as exc: + logger.debug("Gateway session DB reopen failed for %s: %s", + session_key, exc) + return self._create_entry_from_recovered_row( + row=recovered, session_key=session_key, source=source, now=now, + ) def _record_gateway_session_peer( self, session_id: str, @@ -1687,23 +1775,69 @@ def has_any_sessions(self) -> bool: def get_or_create_session( self, source: SessionSource, - force_new: bool = False + force_new: bool = False, ) -> SessionEntry: + """Single-flight session lookup/create per routing key. + + Calls for different keys remain concurrent. Overlapping calls for the + same key share the owner's result, including concurrent ``force_new`` + deliveries, so only one routing transition and SQLite row is created. """ - Get an existing session or create a new one. + session_key = self._generate_session_key(source) + inflight_lock = getattr(self, "_inflight_lock", None) + if inflight_lock is None: + inflight_lock = threading.Lock() + self._inflight_lock = inflight_lock + self._inflight_sessions = {} + + with inflight_lock: + slot = self._inflight_sessions.get(session_key) + if slot is None: + slot = _SessionFlight() + self._inflight_sessions[session_key] = slot + owner = True + else: + owner = False + + if not owner: + slot.event.wait() + if slot.error is not None: + raise slot.error + assert slot.result is not None + return slot.result + + try: + result = self._get_or_create_session_impl(source, force_new=force_new) + slot.result = result + return result + except BaseException as exc: + slot.error = exc + raise + finally: + slot.event.set() + with inflight_lock: + self._inflight_sessions.pop(session_key, None) - Evaluates reset policy to determine if the existing session is stale. - Creates a session record in SQLite when a new session starts. + def _get_or_create_session_impl( + self, + source: SessionSource, + force_new: bool = False, + ) -> SessionEntry: + """Perform one session routing transition for the single-flight owner. + + All blocking I/O (SQLite SELECTs, routing-index rewrite + ``os.fsync``, + recovery DB queries) is performed *outside* ``self._lock``. The lock + protects only ``_entries`` / ``_loaded`` mutations. """ session_key = self._generate_session_key(source) now = _now() - # SQLite calls are made outside the lock to avoid holding it during I/O. - # All _entries / _loaded mutations are protected by self._lock. db_end_session_id = None db_create_kwargs = None existing_session_id = None + force_new_observed_entry = None + # ---- Phase 0: lock read -- existing session_id for compression tip ---- if not force_new: with self._lock: self._ensure_loaded_locked() @@ -1711,13 +1845,52 @@ def get_or_create_session( if entry is not None: existing_session_id = entry.session_id - # Look up the compression continuation outside the lock (DB I/O). + # Compression tip lookup outside the lock (DB I/O). canonical_existing_session_id = ( self._compression_tip_for_session_id(existing_session_id) if existing_session_id else None ) + # ---- Phase 1: lock read -- get entry snapshot for stale/reset checks ---- + _stale_session_id = None + _entry_for_checks = None + with self._lock: + self._ensure_loaded_locked() + if force_new: + force_new_observed_entry = self._entries.get(session_key) + if session_key in self._entries and not force_new: + _entry_for_checks = self._entries[session_key] + _stale_session_id = _entry_for_checks.session_id + + # ---- Phase 1b: no-lock I/O -- stale check + reset policy ---- + _is_stale = False + _reset_reason = None + if _entry_for_checks is not None and _stale_session_id is not None: + _is_stale = self._is_session_ended_in_db(_stale_session_id) + if _entry_for_checks.suspended: + _reset_reason = "suspended" + elif _entry_for_checks.resume_pending: + _reset_reason = self._should_reset(_entry_for_checks, source) + if not _reset_reason: + _fw = auto_continue_freshness_window() + _ref_time = ( + _entry_for_checks.last_resume_marked_at + or _entry_for_checks.updated_at + ) + if _fw > 0 and (now - _ref_time).total_seconds() > _fw: + _reset_reason = "resume_pending_expired" + else: + _reset_reason = self._should_reset(_entry_for_checks, source) + + # ---- Phase 2: lock write -- apply decisions to _entries ---- + _needs_save = False + _needs_recover = False + entry: Optional[SessionEntry] = None + was_auto_reset = False + auto_reset_reason = None + reset_had_activity = False + with self._lock: self._ensure_loaded_locked() @@ -1727,25 +1900,13 @@ def get_or_create_session( entry, existing_session_id, canonical_existing_session_id ) - # Self-heal stale routing: if this session_key still points at - # a session that has ALREADY been ended in state.db (end_reason - # set), the in-memory sessions.json entry is stale. Reusing it - # would route every incoming message into a closed session and - # silently drop it — with no log, no error, no response — until - # the gateway restarts and _prune_stale_sessions_locked() clears - # it (#54878 — the live-gateway variant of #52804/FM9, which - # only the startup prune previously caught). - # - # Drop the stale entry and fall through to the recovery path - # below. Leaving db_end_session_id None routes us into - # _recover_session_from_db, whose finder - # (hermes_state.find_latest_gateway_session_for_peer) selects - # rows WHERE `ended_at IS NULL OR end_reason = 'agent_close'` - # — so it REOPENS gateway-cleanup-ended ('agent_close') rows and - # resumes the SAME session_id (transcript preserved), but returns - # None for any other end_reason (e.g. /new), which then correctly - # starts a fresh session. - if self._is_session_ended_in_db(entry.session_id): + if _is_stale and entry.session_id == _stale_session_id: + # Stale routing self-heal (#54878): the in-memory entry + # points at a session that has ALREADY been ended in + # state.db. Drop it and fall through to recovery/create. + # Recovery finder reopens ``agent_close`` rows (preserving + # the transcript) but returns None for other end_reasons + # (e.g. /new), starting a fresh session. logger.warning( "gateway.session: routing key %r -> %s is ended in " "state.db but still live in sessions.json; dropping " @@ -1754,82 +1915,49 @@ def get_or_create_session( session_key, entry.session_id, ) self._entries.pop(session_key, None) - was_auto_reset = False - auto_reset_reason = None - reset_had_activity = False - # Fall through to the recovery/create path below; the - # stale entry is gone so we must NOT consult its - # suspended/resume/reset state. + entry = None + _needs_recover = True + elif entry.session_id != _stale_session_id: + # Another thread handled this entry during our lock-free + # window. Treat as healthy -- bump updated_at and save. + entry.updated_at = now + _needs_save = True else: - # Auto-reset sessions marked as suspended (e.g. after /stop - # broke a stuck loop — #7536). ``suspended`` is the hard - # forced-wipe signal and always wins over ``resume_pending``, - # so repeated interrupted restarts that escalate via the - # existing ``.restart_failure_counts`` stuck-loop counter - # still converge to a clean slate. - if entry.suspended: - reset_reason = "suspended" - elif entry.resume_pending: - # Restart-interrupted session: preserve the session_id - # and return the existing entry so the transcript reloads - # intact, but still honour normal daily/idle reset policy. - # - # Freshness gate (#46934): the idle/daily policy checks - # ``updated_at``, which is bumped to ``now`` on every - # message — so a zombie session that keeps receiving - # messages never trips it and would resume stale context - # forever. ``last_resume_marked_at`` is set once when - # resume was marked and never bumped per-message, so it - # correctly measures how long resume has been pending. - # If that exceeds the auto-continue freshness window, the - # recovery turn either never ran or failed — treat the - # session as a zombie and fall through to auto-reset. - reset_reason = self._should_reset(entry, source) - if not reset_reason: - _fw = auto_continue_freshness_window() - _ref_time = entry.last_resume_marked_at or entry.updated_at - if _fw > 0 and (now - _ref_time).total_seconds() > _fw: - reset_reason = "resume_pending_expired" - else: - entry.updated_at = now - self._save() - return entry - else: - reset_reason = self._should_reset(entry, source) - if not reset_reason: - entry.updated_at = now - self._save() - return entry - else: - # Session is being auto-reset. + # Stale check clean. Apply reset decision. + if _reset_reason: was_auto_reset = True - auto_reset_reason = reset_reason - # Track whether the expired session had any real - # conversation. total_tokens is never written (token - # counts migrated to agent-direct persistence) so it is - # always 0 — use last_prompt_tokens, updated every turn. + auto_reset_reason = _reset_reason reset_had_activity = entry.last_prompt_tokens > 0 db_end_session_id = entry.session_id + self._entries.pop(session_key, None) + entry = None + _needs_recover = True + else: + entry.updated_at = now + _needs_save = True else: - was_auto_reset = False - auto_reset_reason = None - reset_had_activity = False - - if not force_new and not db_end_session_id: - recovered_entry = self._recover_session_from_db( - session_key=session_key, - source=source, - now=now, - ) - if recovered_entry is not None: - self._entries[session_key] = recovered_entry - self._save() - return recovered_entry + if not force_new: + _needs_recover = True - # Create new session + # ---- Phase 3: no-lock I/O -- recovery + create + save + DB ops ---- + if _needs_recover and db_end_session_id is None: + recovered = self._query_recoverable_session( + session_key=session_key, source=source, now=now, + ) + if recovered is not None: + with self._lock: + published = self._entries.get(session_key) + if published is None: + self._entries[session_key] = recovered + published = recovered + entry = published + _needs_save = True + + if entry is None: + # Create a candidate outside the lock, then publish only if another + # worker has not already populated this routing key. session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" - - entry = SessionEntry( + candidate = SessionEntry( session_key=session_key, session_id=session_id, created_at=now, @@ -1842,20 +1970,34 @@ def get_or_create_session( auto_reset_reason=auto_reset_reason, reset_had_activity=reset_had_activity, ) - - self._entries[session_key] = entry - self._save() - db_create_kwargs = { - "session_id": session_id, - "source": source.platform.value, - "user_id": source.user_id, - "session_key": session_key, - "chat_id": source.chat_id, - "chat_type": source.chat_type, - "thread_id": source.thread_id, - } - - # SQLite operations outside the lock + with self._lock: + current = self._entries.get(session_key) + may_publish = current is None or ( + force_new and current is force_new_observed_entry + ) + if may_publish: + self._entries[session_key] = candidate + published = candidate + else: + published = current + assert published is not None + entry = published + _needs_save = True + if entry is candidate: + db_create_kwargs = { + "session_id": session_id, + "source": source.platform.value, + "user_id": source.user_id, + "session_key": session_key, + "chat_id": source.chat_id, + "chat_type": source.chat_type, + "thread_id": source.thread_id, + } + + if _needs_save: + self._save_entries() + + # SQLite operations outside the lock (unchanged). if self._db and db_end_session_id: try: self._db.end_session(db_end_session_id, "session_reset") diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3d7bed924c7b..8d4eee356f95 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -34,6 +34,7 @@ from gateway.config import HomeChannel, Platform, PlatformConfig from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType from gateway.session import ( + AsyncSessionStore, SessionSource, build_session_key, is_shared_multi_user_session, @@ -86,6 +87,8 @@ def _model_switch_skew_guard() -> Optional[str]: class GatewaySlashCommandsMixin: """In-session slash-command handlers for GatewayRunner.""" + async_session_store: AsyncSessionStore + def _typed_command_prefix_for(self, platform) -> str: """Return the prefix users can always type to reach Hermes commands. @@ -198,7 +201,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer pass # Reset the session - new_entry = self.session_store.reset_session(session_key) + new_entry = await self.async_session_store.reset_session(session_key) # Clear any session-scoped model/reasoning overrides so the next agent # picks up configured defaults instead of previous session switches. @@ -263,7 +266,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_default") else: # No existing session, just create one - new_entry = self.session_store.get_or_create_session(source, force_new=True) + new_entry = await self.async_session_store.get_or_create_session(source, force_new=True) header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_new") # Set session title if provided with /new @@ -495,7 +498,7 @@ async def _handle_status_command(self, event: MessageEvent) -> str: from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) connected_platforms = [p.value for p in self.adapters.keys()] @@ -1061,7 +1064,7 @@ async def _handle_stop_command(self, event: MessageEvent) -> Union[str, Ephemera """ from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_key = session_entry.session_key agent = self._running_agents.get(session_key) @@ -1598,7 +1601,7 @@ async def _on_model_selected( _sess_db = getattr(_self, "_session_db", None) if _sess_db is not None: try: - _sess_entry = _self.session_store.get_or_create_session( + _sess_entry = await _self.async_session_store.get_or_create_session( event.source ) await _sess_db.update_session_model( @@ -1629,7 +1632,7 @@ async def _on_model_selected( # store so the picked model survives a gateway restart # (api_key is never persisted). try: - _self.session_store.set_model_override( + await _self.async_session_store.set_model_override( _session_key, _self._session_model_overrides[_session_key], ) @@ -1840,7 +1843,7 @@ async def _finish_switch() -> str: _sess_db = getattr(self, "_session_db", None) if _sess_db is not None: try: - _sess_entry = self.session_store.get_or_create_session(source) + _sess_entry = await self.async_session_store.get_or_create_session(source) # If this session was auto-reset, consume the flag so the # next regular message's cleanup does not wipe the model # override just stored below (Closes #48031). @@ -1878,8 +1881,9 @@ async def _finish_switch() -> str: # api_key/api_mode are never persisted — they are re-resolved via # runtime provider resolution on rehydration. try: - self.session_store.set_model_override( - session_key, self._session_model_overrides[session_key] + await self.async_session_store.set_model_override( + session_key, + self._session_model_overrides[session_key], ) except Exception: logger.debug( @@ -2142,8 +2146,8 @@ def _resolve_prompt(value): async def _handle_retry_command(self, event: MessageEvent) -> str: """Handle /retry command - re-send the last user message.""" source = event.source - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) # Find the last user message last_user_msg = None @@ -2159,10 +2163,10 @@ async def _handle_retry_command(self, event: MessageEvent) -> str: # Truncate history to before the last user message and persist truncated = history[:last_user_idx] - self.session_store.rewrite_transcript(session_entry.session_id, truncated) + await self.async_session_store.rewrite_transcript(session_entry.session_id, truncated) # Reset stored token count — transcript was truncated session_entry.last_prompt_tokens = 0 - + # Re-send by creating a fake text event with the old message retry_event = MessageEvent( text=last_user_msg, @@ -2189,7 +2193,7 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str: args = (event.get_command_args() or "").strip() lower = args.lower() - mgr, session_entry = self._get_goal_manager_for_event(event) + mgr, session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") @@ -2323,7 +2327,7 @@ async def _handle_subgoal_command(self, event: "MessageEvent") -> str: to invoke while the agent is running. """ args = (event.get_command_args() or "").strip() - mgr, _session_entry = self._get_goal_manager_for_event(event) + mgr, _session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") if not mgr.has_goal(): @@ -2390,8 +2394,8 @@ async def _handle_undo_command(self, event: MessageEvent) -> str: if n < 1: n = 1 - session_entry = self.session_store.get_or_create_session(source) - result = self.session_store.rewind_session(session_entry.session_id, n) + session_entry = await self.async_session_store.get_or_create_session(source) + result = await self.async_session_store.rewind_session(session_entry.session_id, n) if result is None: return t("gateway.undo.nothing") @@ -3090,8 +3094,8 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: https://code.claude.com/docs/en/whats-new/2026-w20). """ source = event.source - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) if not history or len(history) < 4: return t("gateway.compress.not_enough") @@ -3262,32 +3266,40 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: # at it; in place the original transcript is untouched) and lets # the outer handler surface a "compress failed" banner instead. # - # The rewrite runs when EITHER rotation produced a new id OR - # in-place compaction succeeded. It is skipped in the THIRD - # case: _compress_context could NOT rotate AND was not in-place - # (e.g. legacy mode but _session_db unavailable / the DB split - # raised) — there session_id is unchanged for a FAILURE reason, - # and rewrite_transcript() would DELETE the original messages and - # replace them with only the compressed summary (permanent data - # loss #44794, #39704). In in-place mode the unchanged id is - # SUCCESS, so the rewrite is exactly right (and is the durable - # write when the throwaway /compress agent has no _session_db of - # its own). - if rotated or _in_place: - if not self.session_store.rewrite_transcript( + # Only rewrite the transcript when rotation produced a NEW + # session id. In-place compaction does NOT need a rewrite: + # archive_and_compact() has already soft-archived the previous + # active rows and inserted the compacted messages as the new + # active set inside _compress_context(). Calling + # rewrite_transcript() after in-place compaction would invoke + # replace_messages(active_only=False) which DELETEs ALL rows — + # including the archived turns that archive_and_compact() + # deliberately preserved (silent data loss, #61145). + # + # The third case: _compress_context could NOT rotate AND was + # not in-place (e.g. legacy mode but _session_db unavailable / + # the DB split raised) — there session_id is unchanged for a + # FAILURE reason, and rewrite_transcript() would DELETE the + # original messages and replace them with only the compressed + # summary (permanent data loss #44794, #39704). + if rotated: + if not await self.async_session_store.rewrite_transcript( new_session_id, compressed ): raise RuntimeError( f"failed to persist compressed transcript for " f"session {new_session_id}" ) - if rotated: - session_entry.session_id = new_session_id - self.session_store._save() - await asyncio.to_thread( - self._sync_telegram_topic_binding, - source, session_entry, reason="compress-command", - ) + session_entry.session_id = new_session_id + await self.async_session_store._save() + await asyncio.to_thread( + self._sync_telegram_topic_binding, + source, session_entry, reason="compress-command", + ) + elif _in_place: + # archive_and_compact() already persisted the compacted + # transcript inside _compress_context — nothing to do. + pass else: logger.warning( "Manual /compress: session rotation did not occur " @@ -3296,7 +3308,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: "it (#44794)." ) # Reset stored token count — transcript changed, old value is stale - self.session_store.update_session( + await self.async_session_store.update_session( session_entry.session_key, last_prompt_tokens=0 ) new_tokens = estimate_request_tokens_rough( @@ -3445,7 +3457,7 @@ async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> st async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source - session_entry = self.session_store.get_or_create_session(source) + session_entry = await self.async_session_store.get_or_create_session(source) session_id = session_entry.session_id if not self._session_db: @@ -3628,7 +3640,7 @@ async def _list_titled_sessions() -> list[dict]: return t("gateway.resume.blocked_not_owner", name=name) # Check if already on that session - current_entry = self.session_store.get_or_create_session(source) + current_entry = await self.async_session_store.get_or_create_session(source) if current_entry.session_id == target_id: return t("gateway.resume.already_on", name=name) @@ -3636,7 +3648,7 @@ async def _list_titled_sessions() -> list[dict]: self._release_running_agent_state(session_key) # Switch the session entry to point at the old session - new_entry = self.session_store.switch_session(session_key, target_id) + new_entry = await self.async_session_store.switch_session(session_key, target_id) if not new_entry: return t("gateway.resume.switch_failed") self._clear_session_boundary_security_state(session_key) @@ -3673,7 +3685,7 @@ async def _list_titled_sessions() -> list[dict]: title = await self._session_db.get_session_title(target_id) or name # Count messages for context - history = self.session_store.load_transcript(target_id) + history = await self.async_session_store.load_transcript(target_id) msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" @@ -3724,7 +3736,7 @@ async def _handle_sessions_command(self, event: MessageEvent) -> str: # `/sessions all` and enumerate other origins' session ids / titles / # previews / sources — the enumeration half of the /resume IDOR. cross_origin = include_all and self._resume_caller_is_admin(source) - current_entry = self.session_store.get_or_create_session(source) + current_entry = await self.async_session_store.get_or_create_session(source) rows = await asyncio.to_thread( query_session_listing, getattr(self._session_db, "_db", self._session_db), @@ -3773,8 +3785,8 @@ async def _handle_branch_command(self, event: MessageEvent) -> str: session_key = self._session_key_for_source(source) # Load the current session and its transcript - current_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(current_entry.session_id) + current_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(current_entry.session_id) if not history: return t("gateway.branch.no_conversation") @@ -3841,7 +3853,7 @@ async def _handle_branch_command(self, event: MessageEvent) -> str: pass # Switch the session store entry to the new session - new_entry = self.session_store.switch_session(session_key, new_session_id) + new_entry = await self.async_session_store.switch_session(session_key, new_session_id) if not new_entry: return t("gateway.branch.switch_failed") self._clear_session_boundary_security_state(session_key) @@ -3959,7 +3971,7 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: api_key = getattr(agent, "api_key", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None if not provider and getattr(self, "_session_db", None) is not None: try: - _entry_for_billing = self.session_store.get_or_create_session(source) + _entry_for_billing = await self.async_session_store.get_or_create_session(source) persisted = await self._session_db.get_session(_entry_for_billing.session_id) or {} except Exception: persisted = {} @@ -4032,7 +4044,9 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: # Same engine the desktop popover uses (PR #54907). The system # prompt / tools / skills / memory slices read off the live agent; # the conversation slice is estimated from the session transcript. - breakdown_lines = self._context_breakdown_lines(agent, source) + breakdown_lines = await asyncio.to_thread( + self._context_breakdown_lines, agent, source + ) if breakdown_lines: lines.append("") lines.extend(breakdown_lines) @@ -4047,8 +4061,8 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: return "\n".join(lines) # No agent at all -- check session history for a rough count - session_entry = self.session_store.get_or_create_session(source) - history = self.session_store.load_transcript(session_entry.session_id) + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) if history: from agent.model_metadata import estimate_messages_tokens_rough msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")] diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 5cf6d50186a3..b97cbf32d306 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5561,52 +5561,72 @@ def resolve_nous_runtime_credentials( persisted_state = dict(state) state_persisted = False - portal_base_url = ( - _optional_base_url(state.get("portal_base_url")) - or os.getenv("HERMES_PORTAL_BASE_URL") - or os.getenv("NOUS_PORTAL_BASE_URL") - or DEFAULT_NOUS_PORTAL_URL - ).rstrip("/") - - # A persisted/stale portal_base_url is where the refresh token gets - # POSTed on refresh — reject any host outside the allowlist so a - # poisoned value can't exfiltrate the bearer, healing to the default. - # The trusted operator/deployment env override (HERMES_PORTAL_BASE_URL / - # NOUS_PORTAL_BASE_URL) bypasses this gate entirely — mirrors - # NOUS_INFERENCE_BASE_URL's treatment below; the allowlist exists to - # reject an untrusted NETWORK-provided value, not one the operator - # explicitly configured. - env_portal_override = _nous_portal_env_override() - if env_portal_override: - portal_base_url = env_portal_override.rstrip("/") - else: - parsed_portal_url = urlparse(portal_base_url) - if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: - logger.warning( - "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", - portal_base_url, parsed_portal_url.hostname, + def _resolve_effective_routing_metadata() -> tuple[str, str, str, str]: + """Resolve every routing value that shared OAuth state can replace.""" + portal_url = ( + _optional_base_url(state.get("portal_base_url")) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or DEFAULT_NOUS_PORTAL_URL + ).rstrip("/") + + # A persisted/stale portal_base_url is where the refresh token gets + # POSTed on refresh — reject any host outside the allowlist so a + # poisoned value can't exfiltrate the bearer, healing to the default. + # Trusted operator env overrides bypass this network-value gate. + env_portal_override = _nous_portal_env_override() + if env_portal_override: + portal_url = env_portal_override.rstrip("/") + else: + parsed_portal_url = urlparse(portal_url) + portal_host = parsed_portal_url.hostname + loopback_http = ( + parsed_portal_url.scheme == "http" + and portal_host in {"localhost", "127.0.0.1"} ) - portal_base_url = DEFAULT_NOUS_PORTAL_URL + trusted_scheme = ( + parsed_portal_url.scheme == "https" or loopback_http + ) + if ( + not portal_host + or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS + or not trusted_scheme + ): + logger.warning( + "auth: ignoring invalid portal_base_url %r " + "(host %r or scheme not allowed), using default", + portal_url, + portal_host, + ) + portal_url = DEFAULT_NOUS_PORTAL_URL - # Persisted value: validated network-provenance only. The stored - # inference_base_url is re-validated on read so a poisoned/stale - # staging host (persisted before the allowlist existed) heals to the - # production default on the no-refresh read path — this is what gets - # written back to auth.json. The env override is deliberately NOT - # folded in here: it must never be persisted (it's a runtime overlay). - stored_inference_base_url = ( - _validate_nous_inference_url_from_network( - _optional_base_url(state.get("inference_base_url")) + # Re-validate persisted network-provenance on every shared merge. + # The env override is runtime-only and must never be persisted. + stored_inference_url = ( + _validate_nous_inference_url_from_network( + _optional_base_url(state.get("inference_base_url")) + ) + or DEFAULT_NOUS_INFERENCE_URL ) - or DEFAULT_NOUS_INFERENCE_URL - ) - # Effective value used to build the client / returned to callers: - # the NOUS_INFERENCE_BASE_URL env override wins (documented dev/staging - # escape hatch), else the validated stored value. - inference_base_url = ( - _nous_inference_env_override() or stored_inference_base_url - ) - client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) + effective_inference_url = ( + _nous_inference_env_override() or stored_inference_url + ) + effective_client_id = str( + state.get("client_id") or DEFAULT_NOUS_CLIENT_ID + ) + return ( + portal_url, + stored_inference_url, + effective_inference_url, + effective_client_id, + ) + + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() def _persist_state(reason: str) -> None: nonlocal persisted_state, state_persisted @@ -5659,6 +5679,21 @@ def _persist_state(reason: str) -> None: access_token = state.get("access_token") refresh_token = state.get("refresh_token") + if not isinstance(access_token, str) or not access_token: + with _nous_shared_store_lock( + timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) + ): + if _merge_shared_nous_oauth_state(state): + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() + _persist_state("runtime_shared_merge_missing_access_token") + if not isinstance(access_token, str) or not access_token: raise AuthError("No access token found for Nous Portal login.", provider="nous", relogin_required=True) @@ -5673,6 +5708,12 @@ def _persist_state(reason: str) -> None: if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() invoke_jwt_status = _nous_invoke_jwt_status( access_token, scope=state.get("scope"), @@ -5737,6 +5778,11 @@ def _persist_state(reason: str) -> None: inference_base_url = ( _nous_inference_env_override() or stored_inference_base_url ) + # Persist network-derived routing with rotated tokens so + # a later JWT validation failure cannot leave the profile + # and shared stores on stale metadata. Never persist the + # operator-only env overlay. + state["inference_base_url"] = stored_inference_base_url state["obtained_at"] = now.isoformat() state["expires_in"] = access_ttl state["expires_at"] = datetime.fromtimestamp( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 678cdb869af8..981533f15e7e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -93,7 +93,9 @@ def _backup_corrupt_config(config_path: Path) -> Optional[Path]: return None -def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: +def _warn_config_parse_failure( + config_path: Path, exc: Exception, *, fallback: str = "defaults" +) -> None: """Surface a config.yaml parse failure to user, log, and stderr. A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` @@ -110,6 +112,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: timestamped ``.bak`` (best-effort) so the user's recoverable content survives any later rewrite of ``config.yaml`` by the setup wizard or ``hermes config set``. + + ``fallback`` selects the message wording: ``"defaults"`` (fresh process, + nothing else to serve) or ``"last-known-good"`` (in-process retention of + the previously loaded config — see the codex#31188 port in + ``_load_config_impl``). """ try: st = config_path.stat() @@ -122,12 +129,19 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: backup_path = _backup_corrupt_config(config_path) - msg = ( - f"Failed to parse {config_path}: {exc}. " - f"Falling back to default config — every user override " - f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " - f"Fix the YAML and restart." - ) + if fallback == "last-known-good": + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Keeping the previously loaded config for this process — " + f"edits to config.yaml are being IGNORED until the YAML is fixed." + ) + else: + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Falling back to default config — every user override " + f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " + f"Fix the YAML and restart." + ) if backup_path is not None: msg += f" A copy of the corrupted file was saved to {backup_path}." logger.warning(msg) @@ -6229,6 +6243,12 @@ def _deep_merge(base: dict, override: dict) -> dict: Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id`` intact. + + An empty section key in config.yaml (``terminal:`` with no value) parses + as YAML ``None``; treating that as an override would replace the entire + default dict with ``None`` and crash every downstream consumer that + expects a mapping (#58277). A ``None`` override of a dict default is + ignored — same as the key being absent. """ result = base.copy() for key, value in override.items(): @@ -6238,6 +6258,8 @@ def _deep_merge(base: dict, override: dict) -> dict: and isinstance(value, dict) ): result[key] = _deep_merge(result[key], value) + elif key in result and isinstance(result[key], dict) and value is None: + continue else: result[key] = value return result @@ -6936,7 +6958,45 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: config = _deep_merge(config, user_config) except Exception as e: - _warn_config_parse_failure(config_path, e) + # Last-known-good fallback (port of openai/codex#31188's + # invariant: a parse failure in a policy/config file must not + # silently replace the effective policy with an empty/default + # one). Falling through to DEFAULT_CONFIG here drops EVERY user + # override — including security-critical ``approvals.deny`` + # rules, which are supposed to block commands even under yolo. + # A long-running gateway whose user mid-edits config.yaml into + # broken YAML would silently lose those rules on the next load. + # Within a running process we still have the last successfully + # loaded config — keep serving it until the file is fixed. + # Fresh processes with no last-known-good keep the existing + # DEFAULT_CONFIG fallback. + lkg = _LAST_EXPANDED_CONFIG_BY_PATH.get(path_key) + _warn_config_parse_failure( + config_path, + e, + fallback="last-known-good" if lkg is not None else "defaults", + ) + if lkg is not None: + # save_config() stores the pre-expansion normalized dict + # (env-ref templates preserved); the load path stores the + # expanded one. Expand defensively — idempotent when the + # stored value is already expanded. + from typing import cast as _cast + lkg_copy: Dict[str, Any] = _cast( + Dict[str, Any], _expand_env_vars(copy.deepcopy(lkg)) + ) + if cache_sig is not None: + # Cache under the corrupt file's signature (empty env + # snapshot: always valid) so repeated loads don't + # re-parse the broken file; fixing the file changes the + # signature and triggers a normal reload. + _empty_env: Dict[str, Optional[str]] = {} + _LOAD_CONFIG_CACHE[path_key] = ( + cache_sig[0], cache_sig[1], + cache_sig[2], cache_sig[3], + lkg_copy, _empty_env, + ) + return copy.deepcopy(lkg_copy) if want_deepcopy else lkg_copy normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 08cfe322b878..d283f2a10138 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -340,13 +340,61 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list if slug == "moa": # MoA is a virtual routing mode, not an independently configured # provider. Hide it from explicit-only pickers unless it is the - # current provider (handled above). + # current provider (handled above) or the user explicitly wrote an + # enabled MoA preset into config.yaml. Use raw config so the + # DEFAULT_CONFIG preset does not make every desktop picker show MoA. + if _raw_config_has_enabled_moa_preset(): + kept.append(row) continue if is_provider_explicitly_configured(slug): kept.append(row) return kept +def _raw_config_has_enabled_moa_preset() -> bool: + """Return True when the user's raw config explicitly enables MoA. + + ``load_config()`` includes ``DEFAULT_CONFIG["moa"].presets.default`` for + everyone. Explicit-only model pickers must not treat that default as a user + choice, but they should keep MoA visible once the user has saved at least + one enabled preset (or an older flat MoA config) in their own config.yaml. + """ + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + + if not isinstance(raw, dict): + return False + moa = raw.get("moa") + if not isinstance(moa, dict): + return False + + presets = moa.get("presets") + if isinstance(presets, dict): + for name, preset in presets.items(): + if not str(name or "").strip(): + continue + if not isinstance(preset, dict): + return True + if preset.get("enabled", True): + return True + return False + + legacy_keys = { + "reference_models", + "aggregator", + "reference_temperature", + "aggregator_temperature", + "max_tokens", + "reference_max_tokens", + "fanout", + } + return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True)) + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6150b141537b..518e74eb0647 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -6789,7 +6789,9 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] ``"recent_success"`` A completed run exists within ``_RESPAWN_GUARD_SUCCESS_WINDOW`` seconds. Useful work already succeeded for this task; wait for - human review rather than immediately re-spawning. + human review rather than immediately re-spawning. Bypassed when an + explicit re-queue event (status change, promote, unblock, reclaim) + arrives AFTER that completion — that's a deliberate re-run request. ``"active_pr"`` A GitHub PR URL appears in a recent task comment (within @@ -6852,13 +6854,29 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] return "blocker_auth" # 3. Completed run within guard window — proof of recent success. + # Exception: an explicit re-queue AFTER that success (an operator + # dragging done→ready, a dependency re-promotion, an unblock, a + # reclaim) is a deliberate "run it again" — honor it instead of + # deferring. Without this, a manual done→ready just sits there, + # silently held by the guard, until the window elapses. cutoff = now - _RESPAWN_GUARD_SUCCESS_WINDOW - if conn.execute( - "SELECT id FROM task_runs " - "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ?", + recent_completed = conn.execute( + "SELECT ended_at FROM task_runs " + "WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? " + "ORDER BY ended_at DESC LIMIT 1", (task_id, cutoff), - ).fetchone(): - return "recent_success" + ).fetchone() + if recent_completed: + completed_at = int(recent_completed["ended_at"] or 0) + requeued_after = conn.execute( + "SELECT 1 FROM task_events " + "WHERE task_id = ? AND created_at >= ? " + "AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') " + "LIMIT 1", + (task_id, completed_at), + ).fetchone() + if not requeued_after: + return "recent_success" # 4. GitHub PR URL in a recent comment — prior worker already opened a PR. pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW @@ -7768,9 +7786,18 @@ def _default_spawn( # attributed correctly regardless of how the child loads config. env["HERMES_PROFILE"] = profile_arg + # A worker must NEVER boot the interactive TUI: an inherited HERMES_TUI=1 + # or a `display.interface: tui` in the profile's config would send the + # quiet chat run into the Ink TUI, whose no-TTY bail-out exits 0 without + # doing the task → "protocol violation" on every attempt. `--cli` is the + # highest-precedence interface override; dropping the env var covers + # older hermes builds on PATH that predate the flag's precedence. + env.pop("HERMES_TUI", None) + cmd = [ *_resolve_hermes_argv(), "-p", profile_arg, + "--cli", # Worker subprocesses switch to a profile-scoped HERMES_HOME above, # so they see that profile's shell-hook allowlist instead of the # dispatcher's root allowlist. Pass --accept-hooks explicitly so diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 81da4c191561..8173f1e33388 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -530,7 +530,20 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. + + Terminal statuses are exempt: a done/archived card has nothing left + to retry, so a lingering failure streak is history, not a signal. + (``complete_task`` resets the counter, but a manual done — e.g. a + dashboard drag — ends no run and used to leave the flag stuck.) + + A fresh attempt in flight (``running``) is also exempt: retrying a + task should clear the stale failure banner until this attempt also + resolves. Otherwise a card that's actively trying again still shows + "failed Nx", which reads as a current failure. It re-fires if the new + run fails too (status leaves ``running`` with a recorded outcome). """ + if _task_field(task, "status") in ("done", "archived", "running"): + return [] threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), @@ -649,7 +662,20 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: total failures) so the operator gets a crash-specific heads-up before the unified rule kicks in. Suppresses itself when the unified rule is also about to fire, to avoid double-flagging. + + Terminal statuses are exempt for the same reason as + ``repeated_failures`` — with one extra wrinkle: this rule reads run + history, and a manual done (dashboard drag) appends no ``completed`` + run to break the crash streak, so the flag was permanent (#kanban + desktop dogfood). Done means done. + + ``running`` is exempt too: a fresh attempt is in flight, and its + in-flight run (no outcome yet) doesn't break the trailing crash scan, + so a retried card kept showing "crashed Nx" over an active run. The + banner re-fires if the new attempt also crashes. """ + if _task_field(task, "status") in ("done", "archived", "running"): + return [] failure_threshold = int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f450a76666f3..9e5267fe428c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -148,7 +148,17 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: """Earliest TUI decision, usable before argparse/config imports. Precedence: explicit ``--cli`` wins (forces classic REPL), then - ``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config. + explicit ``--tui``/``HERMES_TUI=1``, then a real-TTY gate (a + non-interactive stdio can't host the Ink UI, so ambient config never + boots it there), then ``display.interface`` in config. + + The TTY gate is load-bearing for headless spawners — kanban workers, + cron jobs, pipes run ``hermes … chat -q`` with stdio on a pipe. This + is the earliest launch decision (it runs before ``cmd_chat`` / + ``_resolve_use_tui``), so a ``display.interface: tui`` default used to + boot the TUI here — whose no-TTY bail-out exits 0 without doing the + task → "protocol violation" on every attempt. An explicit ``--tui`` + still reaches the informative bail-out. """ if argv is None: argv = sys.argv[1:] @@ -156,6 +166,11 @@ def _wants_tui_early(argv: "list[str] | None" = None) -> bool: return False if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv: return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False return _config_default_interface_early() == "tui" @@ -2193,16 +2208,34 @@ def _resolve_use_tui(args) -> bool: Precedence (highest first): 1. ``--cli`` flag → always classic REPL - 2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI - 3. ``display.interface`` config value ("cli" | "tui") - 4. default → classic REPL + 2. ``--tui`` flag → always TUI (explicit ask) + 3. no TTY → always classic (ambient prefs don't apply) + 4. ``HERMES_TUI=1`` env → TUI + 5. ``display.interface`` config value ("cli" | "tui") + 6. default → classic REPL Explicit flags always win over config so muscle memory and scripts keep working regardless of the configured default. + + The TTY gate (3) is load-bearing: ambient TUI preferences (env var or + config default) must never hijack a NON-interactive invocation. Kanban + workers, cron jobs, and pipelines run ``hermes … chat -q`` with stdout + on a pipe; booting the Ink TUI there hits its no-TTY bail-out, which + prints a resume hint and exits 0 — a kanban worker then dies with + "exited cleanly without calling kanban_complete — protocol violation" + on every attempt (found dogfooding the desktop kanban board). A user + who *explicitly* passes ``--tui`` still gets the informative bail-out. """ if getattr(args, "cli", False): return False - if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": + if getattr(args, "tui", False): + return True + try: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return False + except Exception: + return False + if os.environ.get("HERMES_TUI") == "1": return True try: from hermes_cli.config import load_config @@ -2325,7 +2358,11 @@ def cmd_chat(args): except Exception: pass - # --yolo: bypass all dangerous command approvals + # --yolo: bypass all dangerous command approvals. + # Also set in main() before _prepare_agent_startup() — that is the + # authoritative site because it runs before tool imports freeze + # _YOLO_MODE_FROZEN. This redundant set is a safety net for callers + # that invoke cmd_chat directly (e.g. subcommand dispatch). if getattr(args, "yolo", False): os.environ["HERMES_YOLO_MODE"] = "1" @@ -12377,6 +12414,15 @@ def _should_background_mcp_startup(args) -> bool: def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" + # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY + # plugin/tool discovery below imports tools.approval, which freezes + # _YOLO_MODE_FROZEN at import time (PR #7994 security design). main()'s + # dispatch path also sets this earlier, but _prepare_agent_startup() is + # reachable from other launchers too (e.g. the Termux fast-CLI path), + # so the guarantee lives here where the import is actually triggered + # (#60328). + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" _apply_safe_mode(args) _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) @@ -14594,6 +14640,15 @@ def _export_one(session_id: str): cmd_version(args) return + # --yolo: set HERMES_YOLO_MODE *before* plugin discovery. The call to + # _prepare_agent_startup() below triggers discover_plugins() → tool + # imports, and tools.approval freezes _YOLO_MODE_FROZEN at module + # import time (PR #7994, security hardening against prompt-injection). + # If the env var is set only later (e.g. inside cmd_chat), the frozen + # value is already False and --yolo silently does nothing. + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + # Discover Python plugins and register shell hooks once, before any # command that can fire lifecycle hooks. Both are idempotent; gated # so introspection/management commands (hermes hooks list, cron diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 4bf75fe3117f..954fcc0ac36f 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -2213,6 +2213,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: "api_url": api_url, "api_key": api_key, "models": [], + "has_explicit_models": False, "discover_models": discover, "extra_headers": entry_extra_headers, } @@ -2235,7 +2236,10 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: if default_model and default_model not in groups[group_key]["models"]: groups[group_key]["models"].append(default_model) - for model_id in _declared_model_ids(entry.get("models", {})): + declared_models = _declared_model_ids(entry.get("models", {})) + if declared_models: + groups[group_key]["has_explicit_models"] = True + for model_id in declared_models: if model_id not in groups[group_key]["models"]: groups[group_key]["models"].append(model_id) @@ -2298,11 +2302,13 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # the (possibly partial) ``models:`` subset configured for # context-length overrides with the full live catalog. # This is the Bifrost / aggregator-gateway case. - # - Without an api_key but with an explicit ``models:`` list - # (or top-level ``model:``), the user is narrowing a public - # endpoint to a specific subset (e.g. ollama.com /v1/models - # returns 35 models but the user only wants 4). Preserve the - # explicit list and skip live discovery. + # - Without an api_key but with an explicit ``models:`` list, + # the user is narrowing a public endpoint to a specific subset + # (e.g. ollama.com /v1/models returns 35 models but the user + # only wants 4). Preserve the explicit list and skip live + # discovery. The singular ``model:`` field is only the current + # active selection and must not suppress discovery on local + # no-key endpoints. # - Without an api_key AND no explicit models, fall through to # live discovery so bare-endpoint custom providers (local # llama.cpp / Ollama servers) still appear populated. @@ -2320,7 +2326,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: should_probe = ( _can_probe_custom_provider(row_is_current=_grp_is_current) and bool(api_url) - and (bool(api_key) or not grp["models"]) + and (bool(api_key) or not grp.get("has_explicit_models")) and grp.get("discover_models", True) ) if should_probe: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 19006eef3801..03f2d6949f63 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -437,7 +437,6 @@ def _xai_curated_models() -> list[str]: "minimax-m3", "minimax-m2.7", "minimax-m2.5", - "minimax-m3-free", "glm-5.2", "glm-5.1", "glm-5", @@ -447,7 +446,6 @@ def _xai_curated_models() -> list[str]: "deepseek-v4-flash-free", "qwen3.7-plus", "qwen3.6-plus", - "qwen3.6-plus-free", "qwen3.5-plus", "grok-build-0.1", "big-pickle", diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 6b3821ed03e8..24a5a2bcac0b 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -8,7 +8,9 @@ import json import datetime +import secrets from typing import Any, Dict, List +from urllib.parse import quote # --- Icons (Lucide-style SVGs) --- ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' @@ -26,6 +28,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-{script_nonce}'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"> <title>{page_title} @@ -566,13 +569,13 @@ - ", + "arguments": "{}", + } + } + ], + } + ] + + html = _generate_messages_html(messages) + + # Raw, executable markup must never reach the standalone artifact. + assert "" not in html + # The escaped form must be present instead. + assert "<script>alert(1)</script>" in html + + +def test_role_is_escaped_in_html_export(): + messages = [ + { + "role": "", + "content": "hello", + "timestamp": 1700000000, + } + ] + + html = _generate_messages_html(messages) + + assert "" not in html + assert "<img src=x onerror=alert(document.domain)>" in html + # The class attribute must remain a single, well-formed token: a crafted + # role must not break out of it nor split into several unintended classes. + class_value = re.search(r'class="(message message-[^"]*active)"', html) + assert class_value is not None + assert " message-" in class_value.group(1) # exactly one message- class + assert class_value.group(1).count("message-") == 1 + + +def test_known_role_keeps_its_css_class(): + html = _generate_messages_html( + [{"role": "assistant", "content": "hi", "timestamp": 1700000000}] + ) + assert 'class="message message-assistant active"' in html diff --git a/tests/hermes_cli/test_setup_irc.py b/tests/hermes_cli/test_setup_irc.py index 31b263fec353..e74185076554 100644 --- a/tests/hermes_cli/test_setup_irc.py +++ b/tests/hermes_cli/test_setup_irc.py @@ -231,6 +231,18 @@ def test_setup_gateway_irc_counts_as_messaging_platform(self, monkeypatch, capsy monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *a, **kw: False) monkeypatch.setattr(setup_mod, "prompt_choice", lambda *a, **kw: 0) + # Select ONLY the IRC row. Without this, the non-TTY checklist + # falls back to its cancel value (the pre-selected "configured" + # platforms) — on a dev machine with real platforms configured + # that runs their interactive setup_fn, which calls input() and + # dies under captured stdin. IRC's setup_fn is a no-op lambda. + monkeypatch.setattr( + setup_mod, + "prompt_checklist", + lambda title, items, pre=None: [ + i for i, item in enumerate(items) if "IRC" in item + ], + ) monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 8fbb063f620e..31365fcf2275 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -46,6 +46,34 @@ def test_missing_skills_key(self): from hermes_cli.skills_config import get_disabled_skills assert get_disabled_skills({"other": "value"}) == set() + def test_null_skills_section(self): + """``skills:`` with no value (YAML null) must not crash (#13026).""" + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": None}) == set() + assert get_disabled_skills({"skills": None}, platform="telegram") == set() + + def test_null_disabled_key(self): + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": {"disabled": None}}) == set() + + def test_scalar_disabled_is_single_skill_not_characters(self): + """``disabled: my-skill`` (bare scalar) is one skill name, not a + set of its characters (#13026).""" + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": {"disabled": "my-skill"}}) == {"my-skill"} + + def test_scalar_platform_disabled(self): + from hermes_cli.skills_config import get_disabled_skills + config = {"skills": { + "disabled": ["global-skill"], + "platform_disabled": {"telegram": "tg-skill"}, + }} + assert get_disabled_skills(config, platform="telegram") == {"global-skill", "tg-skill"} + + def test_non_dict_skills_section(self): + from hermes_cli.skills_config import get_disabled_skills + assert get_disabled_skills({"skills": "oops"}) == set() + def test_empty_disabled_list(self): from hermes_cli.skills_config import get_disabled_skills assert get_disabled_skills({"skills": {"disabled": []}}) == set() diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 519ccf1518e0..8caffe2dbdb3 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -863,6 +863,107 @@ def test_get_media_requires_auth(self): ) assert resp.status_code == 401 + # ── POST /api/chat/image-upload (browser clipboard/drop images) ───── + + def test_chat_image_upload_writes_to_default_profile_images(self): + from hermes_constants import get_hermes_home + + data_url = ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk" + "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + ) + + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": data_url, "filename": "../../clip.png"}, + ) + + assert resp.status_code == 200 + data = resp.json() + target = Path(data["path"]) + assert data["ok"] is True + assert data["mime_type"] == "image/png" + assert target.parent == get_hermes_home() / "images" + assert target.name.startswith("dashboard_") + assert target.name.endswith("_clip.png") + assert target.is_file() + assert target.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + def test_chat_image_upload_writes_to_requested_profile_images(self): + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + + resp = self.client.post( + "/api/chat/image-upload?profile=worker", + json={ + "data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA=", + "filename": "drop.gif", + }, + ) + + assert resp.status_code == 200 + target = Path(resp.json()["path"]) + assert target.parent == worker_home / "images" + assert target.is_file() + assert target.read_bytes().startswith(b"GIF89a") + + def test_chat_image_upload_rejects_non_image_payload(self): + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:text/plain;base64,aGVsbG8="}, + ) + + assert resp.status_code == 400 + assert "image" in resp.json()["detail"].lower() + + def test_chat_image_upload_rejects_spoofed_image_payload(self): + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:image/png;base64,aGVsbG8=", "filename": "fake.png"}, + ) + + assert resp.status_code == 400 + assert "unsupported image type" in resp.json()["detail"].lower() + + def test_chat_image_upload_rejects_unknown_profile(self): + resp = self.client.post( + "/api/chat/image-upload?profile=missing-profile", + json={"data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA="}, + ) + + assert resp.status_code == 404 + assert "does not exist" in resp.json()["detail"] + + def test_chat_image_upload_enforces_image_size_cap(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_CHAT_IMAGE_UPLOAD_MAX_BYTES", 4) + + resp = self.client.post( + "/api/chat/image-upload", + json={ + "data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE=", + "filename": "large.png", + }, + ) + + assert resp.status_code == 413 + assert "too large" in resp.json()["detail"].lower() + + def test_chat_image_upload_requires_auth(self): + from hermes_cli.web_server import _SESSION_HEADER_NAME + + resp = self.client.post( + "/api/chat/image-upload", + json={"data_url": "data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA="}, + headers={_SESSION_HEADER_NAME: "wrong-token"}, + ) + + assert resp.status_code == 401 + # ── Dashboard font override ───────────────────────────────────────── def test_get_dashboard_font_defaults_to_theme(self): diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index 185b7bac32a5..abcdbb681fa4 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -1,5 +1,7 @@ """Regression tests for dashboard cron job profile routing.""" +from concurrent.futures import ThreadPoolExecutor +import json from queue import Empty, SimpleQueue import threading @@ -34,7 +36,7 @@ def _drain_queue(q): return values -def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles): +def test_call_cron_for_profile_routes_storage_without_mutating_globals(isolated_profiles): from cron import jobs as cron_jobs from hermes_cli import web_server @@ -62,6 +64,135 @@ def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_prof assert cron_jobs.OUTPUT_DIR == old_output_dir +def test_fire_cron_job_scopes_store_and_runtime_home_together( + isolated_profiles, + monkeypatch, +): + """A profile fire must execute and persist under the same profile home.""" + from cron import jobs as cron_jobs + from cron import scheduler + from hermes_cli import web_server + + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + default_home = isolated_profiles["default"] + worker_home = isolated_profiles["worker_alpha"] + monkeypatch.setattr(scheduler, "_hermes_home", None) + captured = {} + + class RecordingProvider: + def fire_due(self, job_id, *, adapters=None, loop=None): + captured["job_id"] = job_id + captured["runtime_home"] = scheduler._get_hermes_home() + captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file + return True + + monkeypatch.setattr( + "cron.scheduler_provider.resolve_cron_scheduler", + lambda: RecordingProvider(), + ) + + outer_token = set_hermes_home_override(default_home) + try: + assert web_server._fire_cron_job_for_profile("worker_alpha", "worker-job") is True + assert captured == { + "job_id": "worker-job", + "runtime_home": worker_home, + "jobs_file": worker_home / "cron" / "jobs.json", + } + assert scheduler._get_hermes_home() == default_home + finally: + reset_hermes_home_override(outer_token) + + +def test_profile_call_cannot_retarget_ticker_store_mid_write( + isolated_profiles, + monkeypatch, +): + """A dashboard profile call must not redirect a concurrent ticker save.""" + from cron import jobs as cron_jobs + from hermes_cli import web_server + + default_cron = isolated_profiles["default"] / "cron" + worker_cron = isolated_profiles["worker_alpha"] / "cron" + default_file = default_cron / "jobs.json" + worker_file = worker_cron / "jobs.json" + default_job = { + "id": "default-job", + "name": "default job", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "2026-07-09T00:00:00+00:00", + } + worker_job = { + "id": "worker-job", + "name": "worker job", + "schedule": {"kind": "interval", "minutes": 60}, + "next_run_at": "2026-07-09T00:00:00+00:00", + } + default_file.write_text(json.dumps({"jobs": [default_job]}), encoding="utf-8") + worker_file.write_text(json.dumps({"jobs": [worker_job]}), encoding="utf-8") + + monkeypatch.setattr(cron_jobs, "CRON_DIR", default_cron) + monkeypatch.setattr(cron_jobs, "JOBS_FILE", default_file) + monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", default_cron / "output") + monkeypatch.setattr( + cron_jobs, + "compute_next_run", + lambda _schedule, _last_run_at=None: "2026-07-10T00:00:00+00:00", + ) + + ticker_loaded = threading.Event() + release_ticker = threading.Event() + profile_entered = threading.Event() + ticker_done = threading.Event() + ticker_thread = threading.local() + original_load_jobs = cron_jobs.load_jobs + + def blocking_load_jobs(): + loaded = original_load_jobs() + if getattr(ticker_thread, "active", False): + ticker_loaded.set() + assert release_ticker.wait(5), "profile call did not enter in time" + return loaded + + def hold_profile_call(): + profile_entered.set() + assert ticker_done.wait(5), "ticker did not finish in time" + return True + + def run_ticker_write(): + ticker_thread.active = True + try: + return cron_jobs.advance_next_run("default-job") + finally: + ticker_done.set() + + monkeypatch.setattr(cron_jobs, "load_jobs", blocking_load_jobs) + monkeypatch.setattr(cron_jobs, "_hold_profile_call", hold_profile_call, raising=False) + + with ThreadPoolExecutor(max_workers=2) as pool: + ticker_future = pool.submit(run_ticker_write) + assert ticker_loaded.wait(5), "ticker did not load the default store" + profile_future = pool.submit( + web_server._call_cron_for_profile, + "worker_alpha", + "_hold_profile_call", + ) + assert profile_entered.wait(5), "profile call did not retarget its store" + release_ticker.set() + assert ticker_future.result(timeout=5) is True + assert profile_future.result(timeout=5) is True + + default_saved = json.loads(default_file.read_text(encoding="utf-8"))["jobs"] + worker_saved = json.loads(worker_file.read_text(encoding="utf-8"))["jobs"] + assert [job["id"] for job in worker_saved] == ["worker-job"] + assert [job["id"] for job in default_saved] == ["default-job"] + assert default_saved[0]["next_run_at"] == "2026-07-10T00:00:00+00:00" + + @pytest.mark.asyncio async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles): from hermes_cli import web_server diff --git a/tests/hermes_cli/test_yolo_startup_order.py b/tests/hermes_cli/test_yolo_startup_order.py new file mode 100644 index 000000000000..6d30b776fbd9 --- /dev/null +++ b/tests/hermes_cli/test_yolo_startup_order.py @@ -0,0 +1,77 @@ +"""Regression tests for #60328: --yolo must set HERMES_YOLO_MODE in +main() before _prepare_agent_startup() triggers tool imports. + +The freeze mechanism in tools.approval (_YOLO_MODE_FROZEN) is correct +by design (PR #7994). The bug was that main() set the env var inside +cmd_chat(), which runs *after* _prepare_agent_startup() has already +imported tools.approval and frozen the constant to False. + +These tests verify the ordering in main() itself: the env var must +already be set at the moment _prepare_agent_startup() is called. +If someone moves the assignment back into cmd_chat(), these tests +fail — catching the exact #60328 regression. +""" + +import os +import sys + + +def _run_main_and_capture_yolo_at_startup(monkeypatch, argv): + """Run main() with *argv*, capturing HERMES_YOLO_MODE at the + moment _prepare_agent_startup is called. + + Returns the captured env var value (or None if unset). + """ + yolo_at_startup = {} + + def spy_prepare_startup(args): + yolo_at_startup["value"] = os.environ.get("HERMES_YOLO_MODE") + + monkeypatch.setattr( + "hermes_cli.main._prepare_agent_startup", spy_prepare_startup + ) + # Stub cmd_chat so main() returns cleanly without entering chat. + monkeypatch.setattr("hermes_cli.main.cmd_chat", lambda args: None) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(sys, "argv", argv) + + from hermes_cli.main import main as cli_main + + cli_main() + + return yolo_at_startup.get("value") + + +def test_top_level_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes --yolo must set HERMES_YOLO_MODE before + _prepare_agent_startup imports tools.approval.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with --yolo. This is the #60328 regression: " + "the env var is set too late (inside cmd_chat, after tool imports)." + ) + + +def test_chat_subcommand_yolo_flag_sets_env_before_startup(monkeypatch): + """hermes chat --yolo must also set HERMES_YOLO_MODE before + _prepare_agent_startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes", "chat", "--yolo"] + ) + assert result == "1", ( + "HERMES_YOLO_MODE was not '1' when _prepare_agent_startup was " + "called from main() with 'chat --yolo'." + ) + + +def test_no_yolo_flag_leaves_env_unset_at_startup(monkeypatch): + """Without --yolo, HERMES_YOLO_MODE must not be set at startup.""" + result = _run_main_and_capture_yolo_at_startup( + monkeypatch, ["hermes"] + ) + assert result is None, ( + "HERMES_YOLO_MODE was unexpectedly set at startup without --yolo." + ) diff --git a/tests/plugins/memory/test_holographic_store.py b/tests/plugins/memory/test_holographic_store.py new file mode 100644 index 000000000000..df351864b001 --- /dev/null +++ b/tests/plugins/memory/test_holographic_store.py @@ -0,0 +1,243 @@ +"""Tests for the holographic MemoryStore shared-connection registry. + +MemoryStore instances pointing at the same database file must share one +process-wide SQLite connection and one re-entrant lock. Multiple providers +coexist in a single process (the main agent plus every delegate_task +subagent); when each instance owned a private connection they raced as +independent WAL writers and intermittently failed with "database is locked". + +Covers: connection sharing/refcounting, close() semantics, cross-instance +visibility, concurrent multi-instance writers, and write-lock release after +a failed write. +""" + +import sqlite3 +import threading + +import pytest + +from plugins.memory.holographic.store import MemoryStore + + +@pytest.fixture(autouse=True) +def _clean_shared_registry(): + """Each test starts and ends with an empty shared-connection registry.""" + # Drop any leakage from earlier tests in the same process. + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + yield + leaked = list(MemoryStore._shared) + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + assert not leaked, f"test leaked shared connections: {leaked}" + + +@pytest.fixture +def db_path(tmp_path): + return tmp_path / "memory_store.db" + + +class TestSharedConnection: + def test_same_path_shares_one_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + assert a._conn is b._conn + assert a._lock is b._lock + assert len(MemoryStore._shared) == 1 + assert MemoryStore._shared[str(a.db_path)]["refs"] == 2 + finally: + a.close() + b.close() + + def test_different_paths_get_distinct_connections(self, tmp_path): + a = MemoryStore(tmp_path / "one.db") + b = MemoryStore(tmp_path / "two.db") + try: + assert a._conn is not b._conn + assert len(MemoryStore._shared) == 2 + finally: + a.close() + b.close() + + def test_symlinked_path_shares_connection(self, tmp_path): + """A symlink to the same DB file must hit the same registry entry — + otherwise two connections to one file silently reintroduce the + multi-writer contention the registry exists to prevent.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + link_dir = tmp_path / "link" + link_dir.symlink_to(real_dir) + + a = MemoryStore(real_dir / "memory_store.db") + b = MemoryStore(link_dir / "memory_store.db") + try: + assert a._conn is b._conn + assert len(MemoryStore._shared) == 1 + finally: + a.close() + b.close() + + def test_writes_visible_across_instances(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + try: + fact_id = a.add_fact("Hermes likes shared connections", category="test") + facts = b.list_facts(category="test") + assert [f["fact_id"] for f in facts] == [fact_id] + finally: + a.close() + b.close() + + def test_schema_initialised_once_per_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) # must not re-run schema init / WAL probe + try: + assert MemoryStore._shared[str(a.db_path)]["ready"] is True + b.add_fact("schema still works") + finally: + a.close() + b.close() + + +class TestCloseSemantics: + def test_closing_one_instance_keeps_sibling_alive(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + try: + # The shared connection must survive the sibling's close(). + fact_id = b.add_fact("survivor write") + assert fact_id > 0 + finally: + b.close() + + def test_last_close_releases_connection(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + conn = a._conn + a.close() + b.close() + assert MemoryStore._shared == {} + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") + + def test_close_is_idempotent(self, db_path): + a = MemoryStore(db_path) + b = MemoryStore(db_path) + a.close() + a.close() # double close must not steal b's reference + try: + b.add_fact("still alive after double close") + assert MemoryStore._shared[str(b.db_path)]["refs"] == 1 + finally: + b.close() + + def test_context_manager_releases_reference(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("context managed") + assert MemoryStore._shared == {} + + def test_reopen_after_full_close(self, db_path): + with MemoryStore(db_path) as store: + store.add_fact("first lifetime") + with MemoryStore(db_path) as store: + facts = store.list_facts() + assert [f["content"] for f in facts] == ["first lifetime"] + + +class TestConcurrency: + def test_concurrent_multi_instance_writers(self, db_path): + """Many instances writing from many threads must never hit + 'database is locked' — the failure mode of per-instance connections.""" + n_threads, n_facts = 8, 15 + errors: list[BaseException] = [] + + def writer(idx: int) -> None: + store = MemoryStore(db_path) + try: + for i in range(n_facts): + store.add_fact(f"fact thread={idx} seq={i}", category="load") + except BaseException as exc: # noqa: BLE001 - recorded for assert + errors.append(exc) + finally: + store.close() + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"concurrent writers failed: {errors[:3]}" + with MemoryStore(db_path) as store: + facts = store.list_facts(category="load", limit=500) + assert len(facts) == n_threads * n_facts + assert MemoryStore._shared == {} + + def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch): + """A write that raises mid-method must not leave an open transaction + holding the SQLite write lock (autocommit isolation_level=None).""" + broken = MemoryStore(db_path) + sibling = MemoryStore(db_path) + try: + monkeypatch.setattr( + MemoryStore, + "_rebuild_bank", + lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")), + ) + with pytest.raises(RuntimeError, match="boom"): + broken.add_fact("write that fails after the INSERT") + monkeypatch.undo() + + # No dangling transaction: the connection reports autocommit state + # and the sibling can write immediately. + assert broken._conn.in_transaction is False + sibling.add_fact("sibling write right after the failure") + finally: + broken.close() + sibling.close() + + +class TestProviderShutdown: + """The provider's shutdown() must release its shared connection, not just + drop the reference. Leaving finalization to GC keeps the connection (and + its write lock) alive on a long-running gateway, which is exactly the + "database is locked" contention the shared-connection registry removes.""" + + def test_shutdown_releases_shared_connection(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + provider = HolographicMemoryProvider(config={"db_path": str(db_path)}) + provider.initialize("session-shutdown") + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + + provider.shutdown() + + assert provider._store is None + assert MemoryStore._shared == {} + + def test_shutdown_keeps_sibling_provider_alive(self, db_path): + from plugins.memory.holographic import HolographicMemoryProvider + + a = HolographicMemoryProvider(config={"db_path": str(db_path)}) + b = HolographicMemoryProvider(config={"db_path": str(db_path)}) + a.initialize("session-a") + b.initialize("session-b") + assert MemoryStore._shared[str(db_path)]["refs"] == 2 + + a.shutdown() + # Sibling still holds a live, writable connection. + assert MemoryStore._shared[str(db_path)]["refs"] == 1 + assert b._store is not None + b._store.add_fact("write after sibling shutdown") + b.shutdown() + assert MemoryStore._shared == {} diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 5f2996d8ebf8..ba8036407282 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -414,6 +414,19 @@ def test_tags(self): body = p.build_extra_body() assert body["tags"] == nous_portal_tags() + def test_extra_body_with_provider_preferences(self): + from agent.portal_tags import nous_portal_tags + + p = get_provider_profile("nous") + assert p is not None + preferences = {"only": ["deepseek"], "ignore": ["deepinfra"]} + body = p.build_extra_body(provider_preferences=preferences) + + assert body == { + "tags": nous_portal_tags(), + "provider": preferences, + } + def test_auth_type(self): p = get_provider_profile("nous") assert p.auth_type == "oauth_device_code" diff --git a/tests/providers/test_transport_parity.py b/tests/providers/test_transport_parity.py index dec63edf8eb4..b77526917c94 100644 --- a/tests/providers/test_transport_parity.py +++ b/tests/providers/test_transport_parity.py @@ -208,6 +208,21 @@ def test_tags(self, transport): ) assert kw["extra_body"]["tags"] == nous_portal_tags() + def test_provider_preferences(self, transport): + preferences = { + "only": ["deepseek"], + "ignore": ["deepinfra"], + "sort": "throughput", + } + kw = transport.build_kwargs( + model="deepseek/deepseek-v4-flash", + messages=_simple_messages(), + tools=None, + provider_profile=get_provider_profile("nous"), + provider_preferences=preferences, + ) + assert kw["extra_body"]["provider"] == preferences + def test_reasoning_omitted_when_disabled(self, transport): """Nous special case: reasoning omitted entirely when disabled.""" kw = transport.build_kwargs( diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py index eb89cdda9c00..bcec4e3d7c8f 100644 --- a/tests/run_agent/test_agent_guardrails.py +++ b/tests/run_agent/test_agent_guardrails.py @@ -8,10 +8,26 @@ import types +import pytest + from run_agent import AIAgent -from tools.delegate_tool import _get_max_concurrent_children -MAX_CONCURRENT_CHILDREN = _get_max_concurrent_children() +# Pin the concurrency limit instead of reading the runtime config. +# _cap_delegate_task_calls() resolves _get_max_concurrent_children() at CALL +# time (inside a per-test hermetic HERMES_HOME), but this module previously +# froze the value at IMPORT time — before the hermetic fixture ran — so a +# developer machine with delegation.max_concurrent_children in the real +# ~/.hermes/config.yaml saw a different limit at import vs call and the +# truncation tests failed locally while passing on CI. +MAX_CONCURRENT_CHILDREN = 3 + + +@pytest.fixture(autouse=True) +def _pin_max_concurrent_children(monkeypatch): + monkeypatch.setattr( + "tools.delegate_tool._get_max_concurrent_children", + lambda: MAX_CONCURRENT_CHILDREN, + ) # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_background_review_cost_controls.py b/tests/run_agent/test_background_review_cost_controls.py index 5ca47b2a0f99..7e5da983faf4 100644 --- a/tests/run_agent/test_background_review_cost_controls.py +++ b/tests/run_agent/test_background_review_cost_controls.py @@ -8,6 +8,7 @@ Pure-function / config-driven; no live model calls. """ +from typing import Any from unittest.mock import patch from agent import background_review as br @@ -28,6 +29,9 @@ class _FakeAgent: def __init__(self, provider="openai-codex", model="gpt-5.5"): self.provider = provider self.model = model + self._credential_pool: Any = None + self.request_overrides = {} + self.max_tokens: int | None = None def _current_main_runtime(self): return { @@ -56,6 +60,9 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): fake_rp = { "provider": "openrouter", "api_key": "or-key", "base_url": "https://openrouter.ai/api/v1", "api_mode": "chat_completions", + "credential_pool": "routed-pool", + "request_overrides": {"extra_body": {"store": False}}, + "max_output_tokens": 2048, } with patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_rp): @@ -64,6 +71,21 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): assert rt["provider"] == "openrouter" assert rt["model"] == "google/gemini-3-flash-preview" assert rt["api_key"] == "or-key" + assert rt["credential_pool"] == "routed-pool" + assert rt["request_overrides"] == {"extra_body": {"store": False}} + assert rt["max_tokens"] == 2048 + + +def test_unrouted_runtime_keeps_parent_pool_and_overrides(): + agent = _FakeAgent() + agent._credential_pool = "parent-pool" + agent.request_overrides = {"service_tier": "priority"} + agent.max_tokens = 4096 + with patch("hermes_cli.config.load_config", return_value={}): + rt = br._resolve_review_runtime(agent) + assert rt["credential_pool"] == "parent-pool" + assert rt["request_overrides"] == {"service_tier": "priority"} + assert rt["max_tokens"] == 4096 def test_routing_same_model_as_parent_is_not_routed(): diff --git a/tests/run_agent/test_malformed_tool_arguments.py b/tests/run_agent/test_malformed_tool_arguments.py new file mode 100644 index 000000000000..6736182d3043 --- /dev/null +++ b/tests/run_agent/test_malformed_tool_arguments.py @@ -0,0 +1,98 @@ +"""Malformed model tool arguments are rejected at the dispatch boundary.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _make_agent() -> AIAgent: + tool_defs = [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "search", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + with ( + patch("run_agent.get_tool_definitions", return_value=tool_defs), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_cli.config.load_config", return_value={}), + patch("run_agent.OpenAI"), + ): + agent = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.client = MagicMock() + agent.tool_delay = 0 + agent._flush_messages_to_session_db = MagicMock() + return agent + + +def _tool_call(call_id: str, arguments: str): + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name="web_search", arguments=arguments), + ) + + +@pytest.mark.parametrize("dispatch_mode", ["sequential", "concurrent"]) +@pytest.mark.parametrize( + "bad_arguments", + [ + pytest.param("not-json", id="malformed-json"), + pytest.param('"scalar"', id="scalar"), + pytest.param("[]", id="list"), + pytest.param("", id="empty"), + pytest.param('{"query": "cut off', id="truncated"), + ], +) +def test_malformed_arguments_are_rejected_without_blocking_valid_sibling( + dispatch_mode: str, + bad_arguments: str, +): + agent = _make_agent() + assistant_message = SimpleNamespace( + content="", + tool_calls=[ + _tool_call("call-bad", bad_arguments), + _tool_call("call-good", '{"query": "valid"}'), + ], + ) + messages = [] + executed = [] + + def fake_dispatch(name, args, task_id, *positional, **kwargs): + call_id = kwargs.get("tool_call_id") or (positional[0] if positional else None) + executed.append((name, args, call_id)) + return json.dumps({"ok": args["query"]}) + + with ( + patch("run_agent.handle_function_call", side_effect=fake_dispatch), + patch.object(agent, "_invoke_tool", side_effect=fake_dispatch), + patch( + "agent.tool_executor.maybe_persist_tool_result", + side_effect=lambda **kwargs: kwargs["content"], + ), + ): + execute = getattr(agent, f"_execute_tool_calls_{dispatch_mode}") + execute(assistant_message, messages, "task-1") + + assert executed == [("web_search", {"query": "valid"}, "call-good")] + assert [message["tool_call_id"] for message in messages] == ["call-bad", "call-good"] + assert len([message for message in messages if message["tool_call_id"] == "call-bad"]) == 1 + + assert '"error": "Invalid tool arguments"' in messages[0]["content"] + assert "JSON object" in messages[0]["content"] + assert json.loads(messages[1]["content"]) == {"ok": "valid"} diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 171a7c11255d..dfe77d007c1e 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2333,7 +2333,7 @@ def test_interrupt_skips_remaining(self, agent): or "interrupted" in messages[0]["content"].lower() ) - def test_invalid_json_args_defaults_empty(self, agent): + def test_invalid_json_args_are_rejected_without_dispatch(self, agent): tc = _mock_tool_call( name="web_search", arguments="not valid json", call_id="c1" ) @@ -2341,13 +2341,12 @@ def test_invalid_json_args_defaults_empty(self, agent): messages = [] with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc: agent._execute_tool_calls(mock_msg, messages, "task-1") - # Invalid JSON args should fall back to empty dict - args, kwargs = mock_hfc.call_args - assert args[:3] == ("web_search", {}, "task-1") - assert set(kwargs.get("enabled_tools", [])) == agent.valid_tool_names + mock_hfc.assert_not_called() assert len(messages) == 1 assert messages[0]["role"] == "tool" assert messages[0]["tool_call_id"] == "c1" + assert "valid json object" in messages[0]["content"].lower() + assert "tool was not executed" in messages[0]["content"].lower() def test_result_truncation_over_100k(self, agent, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) @@ -3789,6 +3788,48 @@ def test_summary_keeps_provider_preferences_for_openrouter(self, agent): kwargs = agent.client.chat.completions.create.call_args.kwargs assert kwargs["extra_body"]["provider"]["only"] == ["Anthropic"] + def test_summary_keeps_provider_preferences_for_nous(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.providers_allowed = ["deepseek"] + agent.providers_ignored = ["deepinfra"] + agent.provider_sort = "throughput" + agent.provider_require_parameters = True + agent.provider_data_collection = "deny" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"]["tags"] == nous_portal_tags() + assert kwargs["extra_body"]["provider"] == { + "only": ["deepseek"], + "ignore": ["deepinfra"], + "sort": "throughput", + "require_parameters": True, + "data_collection": "deny", + } + + def test_summary_keeps_nous_profile_body_without_routing_preferences(self, agent): + agent.base_url = "https://proxy.example.com/v1" + agent._base_url_lower = agent.base_url.lower() + agent.provider = "nous" + agent.client.chat.completions.create.return_value = _mock_response(content="Summary") + agent._cached_system_prompt = "You are helpful." + + result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60) + + assert result == "Summary" + kwargs = agent.client.chat.completions.create.call_args.kwargs + from agent.portal_tags import nous_portal_tags + + assert kwargs["extra_body"] == {"tags": nous_portal_tags()} + def test_summary_drops_invalid_provider_sort(self, agent): agent.base_url = "https://openrouter.ai/api/v1" agent._base_url_lower = agent.base_url.lower() diff --git a/tests/run_agent/test_switch_model_reapplies_headers.py b/tests/run_agent/test_switch_model_reapplies_headers.py new file mode 100644 index 000000000000..cd24bb622bad --- /dev/null +++ b/tests/run_agent/test_switch_model_reapplies_headers.py @@ -0,0 +1,100 @@ +"""Regression tests for #61099: switch_model must reapply provider-specific +default headers when it rebuilds _client_kwargs from scratch. + +Without _apply_client_headers_for_base_url() in the rebuild path, a /model +switch drops OpenRouter attribution headers (HTTP-Referer / X-Title → logs +show "Unknown") and, worse, functional headers like Kimi's User-Agent +sentinel (403 without it). +""" + +from unittest.mock import MagicMock, patch + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + agent._client_kwargs = {"api_key": "sk-primary", "base_url": base_url} + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_openrouter_reapplies_attribution_headers(mock_ctx_len): + """Switching to an openrouter.ai base_url must attach the OpenRouter + attribution headers (HTTP-Referer / X-Title) to the rebuilt client + kwargs — not ship a bare api_key+base_url client (#61099).""" + agent = _make_agent(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "deepseek/deepseek-chat", + "openrouter", + api_key="sk-or-new", + base_url="https://openrouter.ai/api/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" in headers + assert headers.get("X-Title") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_to_kimi_reapplies_user_agent_sentinel(mock_ctx_len): + """Kimi requires a User-Agent sentinel; a switch to api.kimi.com must + carry it or every request 403s.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model( + "kimi-k2", + "kimi", + api_key="sk-kimi", + base_url="https://api.kimi.com/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert headers.get("User-Agent", "").startswith("claude-code/") + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_away_from_headered_provider_clears_stale_headers(mock_ctx_len): + """Switching FROM a headered provider TO one with no URL-specific headers + must not carry the old provider's headers along.""" + agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1") + agent._client_kwargs["default_headers"] = { + "HTTP-Referer": "https://hermes-agent.nousresearch.com", + "X-Title": "Hermes Agent", + } + + agent.switch_model( + "MiniMax-M3", + "custom:minimax", + api_key="sk-minimax", + base_url="https://api.minimax.io/v1", + ) + + headers = agent._client_kwargs.get("default_headers") or {} + assert "HTTP-Referer" not in headers + assert "X-Title" not in headers diff --git a/tests/run_agent/test_switch_model_stale_base_url.py b/tests/run_agent/test_switch_model_stale_base_url.py new file mode 100644 index 000000000000..bd38eb6c4981 --- /dev/null +++ b/tests/run_agent/test_switch_model_stale_base_url.py @@ -0,0 +1,87 @@ +"""Regression tests for #47828: switch_model must not pair a new provider +label with the previous provider's base_url when the resolver returns no +new base_url for a genuine provider change. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") -> AIAgent: + """Build a minimal AIAgent with a context_compressor, skipping __init__.""" + agent = AIAgent.__new__(AIAgent) + + agent.model = "claude-opus-4.8" + agent.provider = provider + agent.base_url = base_url + agent.api_key = "sk-primary" + agent.api_mode = "chat_completions" + agent.client = MagicMock() + agent.quiet_mode = True + agent._config_context_length = None + + compressor = ContextCompressor( + model=agent.model, + threshold_percent=0.50, + base_url=base_url, + api_key="sk-primary", + provider=provider, + quiet_mode=True, + config_context_length=None, + ) + agent.context_compressor = compressor + agent._primary_runtime = {} + + return agent + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_rejects_stale_base_url_on_provider_change(mock_ctx_len): + """A provider change with no resolved base_url must fail loud instead of + silently keeping the previous provider's endpoint (#47828).""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + with pytest.raises(ValueError, match="no base_url resolved"): + agent.switch_model("MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="") + + # Rollback must leave the agent fully on the old (provider, base_url) pair — + # not a mismatched new-model/old-endpoint hybrid. + assert agent.provider == "copilot" + assert agent.base_url == "https://api.githubcopilot.com" + assert agent.model == "claude-opus-4.8" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_allows_empty_base_url_for_same_provider(mock_ctx_len): + """Re-selecting the SAME provider (e.g. a credential-only refresh) with no + new base_url must keep the current URL — this is not a provider change.""" + agent = _make_agent_with_compressor(provider="openrouter", base_url="https://openrouter.ai/api/v1") + + agent.switch_model("new-model", "openrouter", api_key="sk-new", base_url="") + + assert agent.provider == "openrouter" + assert agent.base_url == "https://openrouter.ai/api/v1" + assert agent.model == "new-model" + + +@patch("agent.model_metadata.get_model_context_length", return_value=131_072) +def test_switch_model_applies_new_base_url_on_provider_change(mock_ctx_len): + """The normal, resolved-correctly path must still work: new provider + + new base_url is applied as-is.""" + agent = _make_agent_with_compressor(provider="copilot", base_url="https://api.githubcopilot.com") + + agent.switch_model( + "MiniMax-M3", "custom:minimax", api_key="sk-minimax", base_url="https://api.minimax.io/v1" + ) + + assert agent.provider == "custom:minimax" + assert agent.base_url == "https://api.minimax.io/v1" + assert agent.model == "MiniMax-M3" + # _primary_runtime must snapshot the coherent pair so it survives every + # subsequent restore_primary_runtime() call across turns. + assert agent._primary_runtime["provider"] == "custom:minimax" + assert agent._primary_runtime["base_url"] == "https://api.minimax.io/v1" diff --git a/tests/run_agent/test_tls_fd_recycle_corruption.py b/tests/run_agent/test_tls_fd_recycle_corruption.py index 29c35612fdfa..4c5f781b5e38 100644 --- a/tests/run_agent/test_tls_fd_recycle_corruption.py +++ b/tests/run_agent/test_tls_fd_recycle_corruption.py @@ -7,9 +7,11 @@ The fix has two prongs: -1. ``force_close_tcp_sockets`` no longer calls ``sock.close()`` — only - ``shutdown(SHUT_RDWR)``. Shutdown unblocks the worker's pending - ``recv``/``send`` without releasing the FD. +1. ``force_close_tcp_sockets`` defaults to ``shutdown(SHUT_RDWR)`` only — + no ``sock.close()``. Shutdown unblocks the worker's pending + ``recv``/``send`` without releasing the FD. Owning-thread dispose + opts into ``release_fds=True`` so already-shutdown sockets do not + accumulate as kernel CLOSED fds (#61979). 2. ``_close_request_client_once`` is thread-aware: a stranger thread (the interrupt-check / stale-call loop) only aborts the sockets and leaves @@ -61,11 +63,10 @@ def _build_fake_client(sock): def test_force_close_tcp_sockets_shutdown_only_no_close(): - """The smoking-gun guarantee: shutdown is called, close is NOT. + """Default path: shutdown is called, close is NOT. - If a future refactor reintroduces ``sock.close()`` here, the - FD-recycling race that corrupted ``kanban.db`` (issue #29507) will - re-open. Pin the contract explicitly. + Stranger-thread abort (#29507) relies on this default. Releasing the + FD from a non-owning thread re-opens the TLS→SQLite corruption race. """ from agent.agent_runtime_helpers import force_close_tcp_sockets @@ -77,11 +78,30 @@ def test_force_close_tcp_sockets_shutdown_only_no_close(): assert n == 1 assert sock.shutdown_calls == 1, "shutdown() must run — it's how we unblock the worker" assert sock.close_calls == 0, ( - "close() must NOT run from this helper — releasing the FD here is the " + "close() must NOT run by default — releasing the FD here is the " "race that wrote TLS bytes into kanban.db (#29507)" ) +def test_force_close_tcp_sockets_release_fds_closes_after_shutdown(): + """Owning-thread dispose path (#61979): shutdown then close. + + httpx does not reliably ``os.close()`` sockets that were already + shutdown, so owner-thread close must release FDs explicitly or they + accumulate in kernel CLOSED state under long-lived gateways. + """ + from agent.agent_runtime_helpers import force_close_tcp_sockets + + sock = _FakeSocket() + client = _build_fake_client(sock) + + n = force_close_tcp_sockets(client, release_fds=True) + + assert n == 1 + assert sock.shutdown_calls == 1 + assert sock.close_calls == 1 + + def test_force_close_tcp_sockets_uses_shut_rdwr(): """Both directions must be shut down so the SSL state machine fully unwinds. @@ -115,7 +135,7 @@ class _AlreadyShut: def shutdown(self, _how): raise OSError("not connected") - def close(self): # pragma: no cover — must not run + def close(self): # pragma: no cover — must not run on default path raise AssertionError("close() must not be called") client = _build_fake_client(_AlreadyShut()) @@ -124,6 +144,27 @@ def close(self): # pragma: no cover — must not run assert force_close_tcp_sockets(client) == 1 +def test_force_close_tcp_sockets_release_fds_swallows_oserror_on_close(): + """Already-closed sockets must not abort the owning-thread sweep.""" + from agent.agent_runtime_helpers import force_close_tcp_sockets + + class _CloseRaises: + def __init__(self): + self.shutdown_calls = 0 + + def shutdown(self, _how): + self.shutdown_calls += 1 + + def close(self): + raise OSError("Bad file descriptor") + + sock = _CloseRaises() + client = _build_fake_client(sock) + + assert force_close_tcp_sockets(client, release_fds=True) == 1 + assert sock.shutdown_calls == 1 + + def test_force_close_tcp_sockets_handles_multiple_pool_entries(): """Walk every pool connection — the bug equally applies to all of them.""" from agent.agent_runtime_helpers import force_close_tcp_sockets @@ -143,6 +184,20 @@ def test_force_close_tcp_sockets_handles_multiple_pool_entries(): assert s.shutdown_calls == 1 assert s.close_calls == 0 + # Owning-thread dispose releases every pool entry's FD. + socks2 = [_FakeSocket(), _FakeSocket(), _FakeSocket()] + entries2 = [ + SimpleNamespace(_connection=SimpleNamespace(_network_stream=SimpleNamespace(_sock=s))) + for s in socks2 + ] + client2 = SimpleNamespace( + _client=SimpleNamespace(_transport=SimpleNamespace(_pool=SimpleNamespace(_connections=entries2))) + ) + assert force_close_tcp_sockets(client2, release_fds=True) == 3 + for s in socks2: + assert s.shutdown_calls == 1 + assert s.close_calls == 1 + # --------------------------------------------------------------------------- # Prong 2: _close_request_client_once is thread-aware. @@ -392,6 +447,38 @@ def test_agent_abort_request_openai_client_does_not_call_client_close(caplog): ), f"missing abort log line; got: {msgs!r}" +def test_agent_close_openai_client_releases_fds(caplog): + """Owning-thread ``_close_openai_client`` must release pool FDs (#61979). + + After #29507 the helper defaulted to shutdown-only; combined with + httpx not closing already-shutdown sockets, gateway processes leaked + CLOSED fds until rlimit. The owner dispose path must opt into + ``release_fds=True``. + """ + from run_agent import AIAgent + + sock = _FakeSocket() + client = _build_fake_client(sock) + client.close = MagicMock() + + agent = AIAgent.__new__(AIAgent) + agent._client_log_context = lambda: "provider=test" + + with caplog.at_level(logging.INFO, logger="run_agent"): + agent._close_openai_client(client, reason="replace:test", shared=True) + + assert sock.shutdown_calls == 1 + assert sock.close_calls == 1 + client.close.assert_called_once() + + msgs = [r.getMessage() for r in caplog.records] + assert any( + "OpenAI client closed (replace:test" in m + and "tcp_force_closed=1" in m + for m in msgs + ), f"missing close log line; got: {msgs!r}" + + def test_agent_abort_request_openai_client_null_client_is_noop(): """A ``None`` client must short-circuit cleanly (defensive).""" from run_agent import AIAgent diff --git a/tests/run_agent/test_verification_continuation_budget.py b/tests/run_agent/test_verification_continuation_budget.py new file mode 100644 index 000000000000..d6f65407e7ab --- /dev/null +++ b/tests/run_agent/test_verification_continuation_budget.py @@ -0,0 +1,146 @@ +"""End-to-end regression coverage for verification budget exhaustion (#61631).""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from run_agent import AIAgent + + +def _response(content="composed report"): + message = SimpleNamespace(content=content, tool_calls=None) + return SimpleNamespace( + choices=[SimpleNamespace(message=message, finish_reason="stop")], + model="test/model", + usage=None, + ) + + +@pytest.fixture +def agent(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + instance = AIAgent( + session_id="verify-budget-test", + api_key="test-key", + base_url="https://example.invalid/v1", + provider="openai-compat", + model="test/model", + max_iterations=1, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + instance._cached_system_prompt = "stable test prompt" + instance._session_db = None + instance._session_json_enabled = False + instance.save_trajectories = False + instance.compression_enabled = False + instance._cleanup_task_resources = lambda *_a, **_kw: None + instance._save_trajectory = lambda *_a, **_kw: None + return instance + + +def _assert_pending_response_survives(agent, result): + assert result["final_response"] == "composed report" + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + assert result["completed"] is False + assert agent._handle_max_iterations.call_count == 0 + assert [message["role"] for message in result["messages"]] == [ + "user", + "assistant", + "user", + "assistant", + ] + + +def test_verify_on_stop_preserves_composed_report_at_budget_limit(agent, monkeypatch): + def model_call(_api_kwargs): + agent._turn_file_mutation_paths = {"changed.py"} + return _response() + + agent._interruptible_api_call = model_call + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1") + + with ( + patch("agent.verification_stop.build_verify_on_stop_nudge", return_value="verify it"), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + _assert_pending_response_survives(agent, result) + assert result["messages"][1]["_verification_stop_synthetic"] is True + assert result["messages"][2]["_verification_stop_synthetic"] is True + + +def test_pre_verify_preserves_composed_report_at_budget_limit(agent, monkeypatch): + def model_call(_api_kwargs): + agent._turn_file_mutation_paths = {"changed.py"} + return _response() + + agent._interruptible_api_call = model_call + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0") + + with ( + patch("hermes_cli.plugins.has_hook", side_effect=lambda name: name == "pre_verify"), + patch( + "hermes_cli.plugins.get_pre_verify_continue_message", + return_value="run project tests", + ), + patch("agent.verify_hooks.max_verify_nudges", return_value=2), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + _assert_pending_response_survives(agent, result) + assert result["messages"][1]["_pre_verify_synthetic"] is True + assert result["messages"][2]["_pre_verify_synthetic"] is True + + +def test_intermediate_ack_uses_summary_instead_of_premature_text(agent, monkeypatch): + agent.valid_tool_names = ["web_search"] + agent._intent_ack_continuation = True + agent._looks_like_codex_intermediate_ack = MagicMock(return_value=True) + agent._interruptible_api_call = lambda _kwargs: _response("I'll inspect the files now") + agent._handle_max_iterations = MagicMock(return_value="verified summary.") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0") + + with ( + patch("hermes_cli.plugins.has_hook", return_value=False), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("inspect /tmp/project") + + assert result["final_response"] == "verified summary." + assert result["turn_exit_reason"] == "max_iterations_reached(1/1)" + agent._handle_max_iterations.assert_called_once() + + +def test_later_verified_response_supersedes_pending_report(agent, monkeypatch): + agent.max_iterations = 2 + agent.iteration_budget.max_total = 2 + answers = iter([_response("premature report"), _response("verified final report")]) + agent._interruptible_api_call = lambda _kwargs: next(answers) + agent._handle_max_iterations = MagicMock(return_value="replacement summary") + monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1") + + with ( + patch( + "agent.verification_stop.build_verify_on_stop_nudge", + side_effect=["verify it", None], + ), + patch("hermes_cli.plugins.invoke_hook", return_value=[]), + ): + result = agent.run_conversation("edit changed.py") + + assert result["final_response"] == "verified final report" + assert result["turn_exit_reason"] == "text_response(finish_reason=stop)" + assert result["completed"] is True + agent._handle_max_iterations.assert_not_called() diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 711956901eed..6b19000aad51 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -424,6 +424,43 @@ def fail_redaction(*_args, **_kwargs): assert "result_text" not in events[1][2] +def test_tui_tool_output_risk_event_exposes_metadata_without_raw_output(monkeypatch): + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "risk-test", + {"tool_progress_mode": "all"}, + ) + + server._on_tool_progress( + "risk-test", + "tool.output_risk", + "web_extract", + tool_call_id="tool-1", + risk_metadata={ + "risk": "high", + "findings": ["prompt_injection"], + "redacted": False, + }, + ) + + assert events == [( + "tool.output_risk", + "risk-test", + { + "tool_id": "tool-1", + "name": "web_extract", + "risk": "high", + "findings": ["prompt_injection"], + "redacted": False, + }, + )] + assert "result" not in events[0][2] + + def test_dispatch_rejects_non_object_request(): resp = server.dispatch([]) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 43c8089f1127..0fe6e0028eb6 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1265,6 +1265,24 @@ def test_named_custom_provider_preserves_provider_name(self, mock_resolve): requested="crof.ai", target_model="deepseek-v4-pro-CEER" ) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_provider_forwards_runtime_request_overrides_and_output_cap(self, mock_resolve): + mock_resolve.return_value = { + "provider": "custom", + "model": "real-model", + "base_url": "https://gateway.example/v1", + "api_key": "gateway-key", + "api_mode": "chat_completions", + "request_overrides": {"extra_body": {"store": False}}, + "max_output_tokens": 3072, + } + creds = _resolve_delegation_credentials( + {"model": "real-model", "provider": "gateway"}, + _make_mock_parent(depth=0), + ) + self.assertEqual(creds["request_overrides"], {"extra_body": {"store": False}}) + self.assertEqual(creds["max_output_tokens"], 3072) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve): """Standard (non-custom) providers must still return runtime identity, @@ -1446,6 +1464,8 @@ def test_provider_override_clears_parent_openrouter_filters( parent.providers_ignored = ["openai/gpt-4o-mini"] parent.providers_order = ["google/gemini-2.5-pro"] parent.provider_sort = "price" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" with patch("run_agent.AIAgent") as MockAgent: mock_child = MagicMock() @@ -1464,6 +1484,44 @@ def test_provider_override_clears_parent_openrouter_filters( self.assertIsNone(kwargs["providers_ignored"]) self.assertIsNone(kwargs["providers_order"]) self.assertIsNone(kwargs["provider_sort"]) + self.assertIs(kwargs["provider_require_parameters"], False) + self.assertEqual(kwargs["provider_data_collection"], "") + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_same_provider_inherits_all_routing_preferences(self, mock_creds, mock_cfg): + mock_cfg.return_value = {"max_iterations": 45} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + parent = _make_mock_parent(depth=0) + parent.provider = "nous" + parent.providers_allowed = ["deepseek"] + parent.providers_ignored = ["deepinfra"] + parent.providers_order = ["anthropic"] + parent.provider_sort = "throughput" + parent.provider_require_parameters = True + parent.provider_data_collection = "deny" + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1 + } + MockAgent.return_value = mock_child + delegate_task(goal="Keep routing", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["providers_allowed"], ["deepseek"]) + self.assertEqual(kwargs["providers_ignored"], ["deepinfra"]) + self.assertEqual(kwargs["providers_order"], ["anthropic"]) + self.assertEqual(kwargs["provider_sort"], "throughput") + self.assertIs(kwargs["provider_require_parameters"], True) + self.assertEqual(kwargs["provider_data_collection"], "deny") @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 36918417938e..f57d52821037 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -427,6 +427,69 @@ def test_search_exception_returns_error(self, mock_get): assert "error" in result +# --------------------------------------------------------------------------- +# Windows MSYS path resolution (salvage of #50488 / #46995) +# --------------------------------------------------------------------------- + +class TestWindowsMsysPathResolution: + """File tools must translate Git Bash drive paths before Path resolution.""" + + def test_absolute_msys_path_normalized_before_windows_resolve(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/c/Users/Mark/project/app.py") + assert str(resolved) == r"C:\Users\Mark\project\app.py" + + def test_cygdrive_path_normalized(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/cygdrive/d/code/main.py") + assert str(resolved) == r"D:\code\main.py" + + def test_relative_path_uses_normalized_msys_cwd(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/c/Users/Mark/project", + ) + + resolved = file_tools._resolve_path_for_task("src/app.py", task_id="msys") + assert str(resolved) == r"C:\Users\Mark\project\src\app.py" + + def test_container_paths_skip_msys_translation(self, monkeypatch): + """WSL/docker Linux paths must not be rewritten as Windows drives.""" + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": True) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/home/don/project", + ) + + resolved = file_tools._resolve_path_for_task("/home/don/.env") + assert str(resolved) == "/home/don/.env" + + # --------------------------------------------------------------------------- # Tool result hint tests (#722) # --------------------------------------------------------------------------- diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 59f01ac56b6b..3e35db414cf9 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -68,6 +68,15 @@ def test_does_not_translate_multi_char_first_segment(self, monkeypatch): monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) assert _msys_to_windows_path("/tmp/foo") == "/tmp/foo" assert _msys_to_windows_path("/home/x") == "/home/x" + # /mnt//... only translates when is a single drive letter. + assert _msys_to_windows_path("/mnt/home/x") == "/mnt/home/x" + + def test_translates_cygdrive_and_wsl_mnt_forms(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("/cygdrive/c/Users/NVIDIA") == r"C:\Users\NVIDIA" + assert _msys_to_windows_path("/mnt/d/Projects/foo") == r"D:\Projects\foo" + assert _msys_to_windows_path("/cygdrive/c") == "C:\\" + assert _msys_to_windows_path("/mnt/c/") == "C:\\" def test_empty_string(self, monkeypatch): monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 3bccb3e07d13..dddfe134edb6 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -350,6 +350,7 @@ def test_should_skip_container_guards(self): def test_isolated_docker_keeps_fast_path(self, monkeypatch): """Isolated Docker still bypasses dangerous-command approval.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") monkeypatch.setattr( "tools.tirith_security.check_command_security", @@ -358,9 +359,26 @@ def test_isolated_docker_keeps_fast_path(self, monkeypatch): has_host_access=False) assert res["approved"] is True + @staticmethod + def _isolate_approval_state(monkeypatch): + """Clear approval state that leaks in from the real user config. + + ``tools.approval`` loads ``command_allowlist`` into module-level + ``_permanent_approved`` at import time. This file imports + ``tools.terminal_tool`` at module level (collection time — BEFORE the + hermetic HERMES_HOME fixture runs), so on a dev machine whose real + config permanently allowlists e.g. "delete in root path" the guard + under test silently approves and the assertions flip. CI never has + such an allowlist, making this a local-only flake. + """ + import tools.approval as A + monkeypatch.setattr(A, "_permanent_approved", set()) + monkeypatch.setattr(A, "_session_approved", {}) + def test_host_bound_docker_requires_approval(self, monkeypatch): """Host-bound Docker dangerous command escalates instead of bypassing.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") monkeypatch.setattr( "tools.tirith_security.check_command_security", @@ -374,6 +392,7 @@ def test_host_bound_docker_requires_approval(self, monkeypatch): def test_execute_code_isolated_docker_keeps_fast_path(self, monkeypatch): """Isolated Docker execute_code still bypasses the guard.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") res = A.check_execute_code_guard("import os", "docker", has_host_access=False) @@ -382,6 +401,7 @@ def test_execute_code_isolated_docker_keeps_fast_path(self, monkeypatch): def test_execute_code_host_bound_docker_requires_approval(self, monkeypatch): """Host-bound Docker execute_code does not get the container fast-path.""" import tools.approval as A + self._isolate_approval_state(monkeypatch) monkeypatch.setenv("HERMES_EXEC_ASK", "1") res = A.check_execute_code_guard( "import os; os.system('rm -rf /workspace')", "docker", diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 4d8b56556d7b..49d1193cf676 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -47,6 +47,70 @@ def echo_handler(args, **kw): result = json.loads(reg.dispatch("echo", {"msg": "hi"})) assert result == {"msg": "hi"} + def test_dispatch_preserves_supported_multimodal_result(self): + reg = ToolRegistry() + multimodal = { + "_multimodal": True, + "content": [{"type": "text", "text": "captured"}], + "text_summary": "captured", + } + reg.register( + name="capture", + toolset="computer_use", + schema=_make_schema("capture"), + handler=lambda args, **kw: multimodal, + ) + + assert reg.dispatch("capture", {}) is multimodal + + def test_dispatch_rejects_unsupported_handler_results_with_structured_error(self): + invalid_results = ({"ok": True}, b"bytes", None, 42) + + for invalid in invalid_results: + reg = ToolRegistry() + reg.register( + name="bad_result", + toolset="core", + schema=_make_schema("bad_result"), + handler=lambda args, _invalid=invalid, **kw: _invalid, + ) + + raw = reg.dispatch("bad_result", {}) + result = json.loads(raw) + + assert isinstance(raw, str) + assert result["error_type"] == "tool_result_contract" + assert result["tool"] == "bad_result" + assert result["result_type"] == type(invalid).__name__ + assert "unsupported result type" in result["error"] + + def test_handler_contract_error_survives_model_tools_pipeline(self): + from model_tools import handle_function_call, registry + + name = "test_invalid_registry_result" + registry.register( + name=name, + toolset="core", + schema=_make_schema(name), + handler=lambda args, **kw: None, + ) + try: + raw = handle_function_call( + name, + {}, + task_id="contract-test", + skip_pre_tool_call_hook=True, + ) + finally: + registry.deregister(name) + + result = json.loads(raw) + assert len(raw) > 0 # downstream sizing/logging remains safe + assert json.loads(json.dumps({"content": raw}))["content"] == raw + assert result["error_type"] == "tool_result_contract" + assert result["tool"] == name + assert result["result_type"] == "NoneType" + class TestGetDefinitions: def test_returns_openai_format(self): diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 535f930e0808..aac919b7a325 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -548,6 +548,14 @@ def setup_method(self): self._managed_patchers = [ patch("tools.web_tools.managed_nous_tools_enabled", return_value=True), patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True), + # ddgs availability is package-presence driven and the plugin + # registry can hold an available ddgs provider. Neutralize both + # fallback surfaces so this class only exercises env-key/gateway + # resolution — otherwise these tests flip on machines where the + # optional ``ddgs`` package is installed (dev venvs) vs CI. + patch("tools.web_tools._ddgs_package_importable", return_value=False), + patch("agent.web_search_registry.get_active_search_provider", return_value=None), + patch("agent.web_search_registry.get_active_extract_provider", return_value=None), ] for p in self._managed_patchers: p.start() @@ -568,6 +576,22 @@ def test_exa_key_only(self): from tools.web_tools import check_web_api_key assert check_web_api_key() is True + def test_null_backend_value_does_not_crash(self): + # config.yaml with ``web:\n backend:`` yields backend=None. The gate + # must not raise AttributeError on None.lower() — mirrors _get_backend. + with patch("tools.web_tools._load_web_config", return_value={"backend": None}): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is False + + def test_null_web_section_does_not_crash(self): + # config.yaml with a present-but-null ``web:`` section makes the raw + # ``.get("web", {})`` return None; _load_web_config must still yield a + # dict so no caller does None.get(...). + with patch("hermes_cli.config.load_config", return_value={"web": None}): + from tools.web_tools import _load_web_config, check_web_api_key + assert _load_web_config() == {} + assert check_web_api_key() is False + def test_firecrawl_key_only(self): with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}): from tools.web_tools import check_web_api_key diff --git a/tests/tools/test_web_tools_dict_urls.py b/tests/tools/test_web_tools_dict_urls.py new file mode 100644 index 000000000000..d58f06954427 --- /dev/null +++ b/tests/tools/test_web_tools_dict_urls.py @@ -0,0 +1,116 @@ +"""Regression tests for model-forwarded web-search result objects.""" + +import json + +import pytest + +from agent import web_search_registry +from agent.web_search_provider import WebSearchProvider +from tools import web_tools + + +class _FakeExtractProvider(WebSearchProvider): + def __init__(self) -> None: + self.received_urls: list[str] = [] + + @property + def name(self) -> str: + return "dict-url-test" + + @property + def display_name(self) -> str: + return "Dict URL Test" + + def is_available(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + async def extract(self, urls, **kwargs): + self.received_urls.extend(urls) + return [ + {"url": url, "title": "", "content": "ok"} + for url in urls + ] + + +@pytest.fixture +def extract_provider(monkeypatch): + with web_search_registry._lock: + previous = dict(web_search_registry._providers) + web_search_registry._providers.clear() + + provider = _FakeExtractProvider() + web_search_registry.register_provider(provider) + monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None) + monkeypatch.setattr( + web_tools, + "_load_web_config", + lambda: {"extract_backend": provider.name}, + ) + + async def _safe(_url): + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _safe) + yield provider + + with web_search_registry._lock: + web_search_registry._providers.clear() + web_search_registry._providers.update(previous) + + +@pytest.mark.asyncio +async def test_web_extract_dispatches_urls_from_search_result_objects(extract_provider): + result = json.loads(await web_tools.web_extract_tool([ + {"url": "https://example.com/a", "title": "A"}, + {"href": "https://example.org/b"}, + ])) + + assert extract_provider.received_urls == [ + "https://example.com/a", + "https://example.org/b", + ] + assert [entry["url"] for entry in result["results"]] == extract_provider.received_urls + + +@pytest.mark.asyncio +async def test_web_extract_reports_invalid_items_without_dispatching_them(extract_provider): + result = json.loads(await web_tools.web_extract_tool([ + {"url": "https://example.com/good"}, + {"title": "missing URL"}, + {"url": 123}, + None, + ])) + + assert extract_provider.received_urls == ["https://example.com/good"] + assert [entry["url"] for entry in result["results"]] == [ + "https://example.com/good", + "", + "", + "", + ] + errors = [entry["error"] for entry in result["results"] if entry["error"]] + assert errors == [ + "Invalid URL item at index 1: expected a URL string or an object " + "with a string 'url' or 'href' field", + "Invalid URL item at index 2: expected a URL string or an object " + "with a string 'url' or 'href' field", + "Invalid URL item at index 3: expected a URL string or an object " + "with a string 'url' or 'href' field", + ] + + +def test_web_extract_registry_dispatch_accepts_search_result_objects( + extract_provider, +): + """The model-facing registry path preserves object URLs through dispatch.""" + raw = web_tools.registry.dispatch("web_extract", { + "urls": [{"url": "https://example.net/from-registry", "title": "R"}], + }) + assert isinstance(raw, str) + result = json.loads(raw) + + assert extract_provider.received_urls == ["https://example.net/from-registry"] + assert result["results"][0]["url"] == "https://example.net/from-registry" diff --git a/tests/tui_gateway/test_finalize_session_persist.py b/tests/tui_gateway/test_finalize_session_persist.py index e1fe7ea53728..c927488de575 100644 --- a/tests/tui_gateway/test_finalize_session_persist.py +++ b/tests/tui_gateway/test_finalize_session_persist.py @@ -54,11 +54,11 @@ def _make_session(agent=None, history=None, session_key="test_key_001"): class TestFinalizeSessionPersist: """Verify _finalize_session flushes messages via _persist_session.""" - def test_persist_called_with_history(self): - """History from session is passed to agent._persist_session. - - When _session_messages is None (not yet set by any turn), - the session["history"] is used as the snapshot. + def test_no_session_messages_skips_persist(self): + """When _session_messages is empty/None the agent processed nothing + this session, so there is nothing new to flush. Falling back to + session["history"] here re-appended already-durable resumed rows as + duplicates, so finalize must NOT write in that case. """ from tui_gateway.server import _finalize_session @@ -66,20 +66,16 @@ def test_persist_called_with_history(self): {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi there"}, ] - agent = _make_agent() + agent = _make_agent() # _session_messages is None session = _make_session(agent=agent, history=history) _finalize_session(session, end_reason="test") - agent._persist_session.assert_called_once() - # snapshot = history (since _session_messages is None) - called_with = agent._persist_session.call_args[0][0] - assert called_with == history - # conversation_history kwarg passed for correct flush indexing - assert agent._persist_session.call_args[1].get("conversation_history") == history - - def test_persist_uses_session_messages_when_available(self): - """agent._session_messages takes priority over session['history'].""" + agent._persist_session.assert_not_called() + + def test_persist_uses_session_messages(self): + """agent._session_messages is flushed via the marker-based dedup path + (no conversation_history — passing the same list neutered the write).""" from tui_gateway.server import _finalize_session history = [{"role": "user", "content": "old"}] @@ -93,10 +89,10 @@ def test_persist_uses_session_messages_when_available(self): _finalize_session(session) - agent._persist_session.assert_called_once() - called_with = agent._persist_session.call_args[0][0] - assert called_with == session_msgs # _session_messages wins - assert agent._persist_session.call_args[1].get("conversation_history") == history + agent._persist_session.assert_called_once_with(session_msgs) + # conversation_history must NOT be passed — it aliases the snapshot and + # makes _flush_messages_to_session_db skip every message. + assert "conversation_history" not in agent._persist_session.call_args[1] def test_commit_memory_still_called(self): """Existing memory commit path is preserved.""" @@ -158,6 +154,7 @@ def test_persist_exception_does_not_block(self): from tui_gateway.server import _finalize_session agent = _make_agent() + agent._session_messages = [{"role": "user", "content": "x"}] agent._persist_session.side_effect = RuntimeError("db is down") session = _make_session( agent=agent, @@ -165,6 +162,7 @@ def test_persist_exception_does_not_block(self): ) _finalize_session(session) # must not raise + agent._persist_session.assert_called_once() # commit_memory_session should still be called agent.commit_memory_session.assert_called_once() @@ -184,6 +182,154 @@ def test_db_end_session_still_called(self, mock_get_db): mock_db.end_session.assert_called_once_with("sess_123", "test") +class TestFinalizeSessionPersistE2E: + """End-to-end: _finalize_session must actually land unflushed turns in + state.db on disconnect/restart. + + The mock-based tests above assert that _persist_session is *called*, but a + call whose arguments neuter the underlying flush persists nothing. These + tests drive the REAL AIAgent flush against a REAL SessionDB, reproducing + the "conversation contains many events yet is absent from state.db across + disconnect/restart" symptom. + """ + + @staticmethod + def _real_agent(db, session_id, session_messages): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent._session_db = db + agent._session_db_created = True + agent.session_id = session_id + agent.platform = "tui" + agent.model = "test-model" + agent._session_messages = session_messages + agent._last_flushed_db_idx = 0 + agent._flushed_db_message_ids = set() + agent._flushed_db_message_session_id = None + agent._persist_disabled = False + agent._cached_system_prompt = None + agent._session_init_model_config = None + agent._parent_session_id = None + agent._session_json_enabled = False + agent.quiet_mode = True + # commit_memory_session runs heavy machinery we don't exercise here. + agent.commit_memory_session = lambda *a, **k: None + return agent + + def test_unflushed_turn_survives_disconnect(self, tmp_path, monkeypatch): + """A completed turn whose transcript flush did NOT durably persist + (messages live only in agent._session_messages / session['history'], + never written to the DB) must be flushed to state.db when the WS + disconnect tears the session down.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-unflushed" + db.create_session(session_id=session_id, source="tui") + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # The live turn list that became session["history"] AND + # agent._session_messages (same object), but was never persisted. + turn = [ + {"role": "user", "content": "scan the repo and summarise"}, + {"role": "assistant", "content": "Here is the summary…"}, + {"role": "user", "content": "now open a PR"}, + {"role": "assistant", "content": "PR opened."}, + ] + agent = self._real_agent(db, session_id, turn) + session = _make_session(agent=agent, history=turn, session_key=session_id) + + assert db.get_messages_as_conversation(session_id) == [] + + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + contents = [m.get("content") for m in after] + assert len(after) == 4, after + assert any("scan the repo" in (c or "") for c in contents), contents + assert any("PR opened" in (c or "") for c in contents), contents + + def test_resumed_session_not_reflushed_as_duplicates(self, tmp_path, monkeypatch): + """A resumed session torn down before any new turn (its transcript is + already durable in the DB) must NOT re-append duplicate rows.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-resumed" + db.create_session(session_id=session_id, source="tui") + loaded = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + for m in loaded: + db.append_message(session_id=session_id, role=m["role"], content=m["content"]) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # Resumed session: history hydrated from the DB, no turn ran, so the + # agent processed nothing this session. + agent = self._real_agent(db, session_id, []) + session = _make_session(agent=agent, history=loaded, session_key=session_id) + + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + assert len(after) == 2, after + + def test_resumed_then_run_turn_not_duplicated(self, tmp_path, monkeypatch): + """A resumed session that RUNS a turn must not have its loaded (durable) + prefix re-appended by finalize. + + This exercises the exact path the ``conversation_history`` argument used + to guard: the in-turn flush stamps the loaded prefix with + ``_DB_PERSISTED_MARKER`` (recognising it as durable), so the marker-only + finalize flush skips it. Without that stamping — or if finalize wrote a + markerless copy — the durable prefix would double. The + ``_session_messages``-empty test above skips the flush entirely, so it + can't catch a duplicate-write regression; this one drives a real flush. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + from hermes_state import SessionDB + import tui_gateway.server as srv + + db = SessionDB(db_path=tmp_path / "state.db") + session_id = "sess-resume-run" + db.create_session(session_id=session_id, source="tui") + loaded = [ + {"role": "user", "content": "remember: my cat is Mochi"}, + {"role": "assistant", "content": "Noted — Mochi."}, + ] + for m in loaded: + db.append_message(session_id=session_id, role=m["role"], content=m["content"]) + monkeypatch.setattr(srv, "_get_db", lambda: db) + + # Live turn list = loaded prefix (same dicts, as run_conversation copies + # conversation_history) + the new turn. + new_turn = [ + {"role": "user", "content": "what's the cat's name?"}, + {"role": "assistant", "content": "Mochi."}, + ] + messages = list(loaded) + new_turn + agent = self._real_agent(db, session_id, messages) + + # Drive the in-turn flush the way run_conversation does — the loaded + # prefix rides in as conversation_history, so it is recognised durable + # (and marker-stamped) while only the new turn is written. + agent._flush_messages_to_session_db(messages, conversation_history=loaded) + assert len(db.get_messages_as_conversation(session_id)) == 4 + + # WS disconnect → finalize. Must re-append nothing. + session = _make_session(agent=agent, history=messages, session_key=session_id) + srv._finalize_session(session, end_reason="ws_disconnect") + + after = db.get_messages_as_conversation(session_id) + assert len(after) == 4, after + + class TestOnSessionEndHook: """Verify on_session_end plugin hook fires on finalize.""" diff --git a/tests/tui_gateway/test_session_platform_resolution.py b/tests/tui_gateway/test_session_platform_resolution.py index da241530604e..dd2b6eda85c6 100644 --- a/tests/tui_gateway/test_session_platform_resolution.py +++ b/tests/tui_gateway/test_session_platform_resolution.py @@ -17,14 +17,18 @@ can be unit-tested without spinning up the full gateway. """ -import importlib - import pytest def _reload_resolver(): + # Plain import — every resolver under test reads the env at CALL time, so + # no reload is needed. importlib.reload(tui_gateway.server) would + # re-register the module's atexit hooks (thread-pool shutdown + + # _shutdown_sessions) on every test; duplicated hooks race the stderr + # buffer at interpreter shutdown (Fatal Python error: + # _enter_buffered_busy) — same flake class as PR #34217. Name kept for + # the existing call sites. import tui_gateway.server as _srv - importlib.reload(_srv) return _srv diff --git a/tests/tui_gateway/test_slash_worker_mcp_discovery.py b/tests/tui_gateway/test_slash_worker_mcp_discovery.py new file mode 100644 index 000000000000..82b59fe4b8ee --- /dev/null +++ b/tests/tui_gateway/test_slash_worker_mcp_discovery.py @@ -0,0 +1,105 @@ +"""Integration coverage for profile-local MCP discovery in slash workers.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import queue +import subprocess +import sys +import textwrap +import threading + +import pytest +import yaml + +pytest.importorskip("mcp.server.fastmcp") + + +def test_profile_local_mcp_tool_is_visible_in_slash_worker(tmp_path): + profile_home = tmp_path / "profile-home" + profile_home.mkdir() + marker = "profile-local-61922" + server = tmp_path / "fastmcp_probe.py" + server.write_text( + textwrap.dedent( + f""" + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("profileprobe") + + @mcp.tool() + def hermes_61922_profile_probe() -> str: + return {marker!r} + + if __name__ == "__main__": + mcp.run(transport="stdio") + """ + ), + encoding="utf-8", + ) + (profile_home / "config.yaml").write_text( + yaml.safe_dump( + { + "mcp_servers": { + "profileprobe": { + "enabled": True, + "command": sys.executable, + "args": [str(server)], + } + } + } + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for key in list(env): + if key.endswith("_API_KEY") or key.endswith("_TOKEN"): + env.pop(key) + env["HERMES_HOME"] = str(profile_home) + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2]) + env["HERMES_SLASH_WATCHDOG_GRACE_S"] = "0" + env["HERMES_SLASH_WATCHDOG_POLL_S"] = "0.05" + proc = subprocess.Popen( + [ + sys.executable, + "-u", + "-m", + "tui_gateway.slash_worker", + "--session-key", + "agent:main:tui:dm:mcp-profile-test", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + cwd=tmp_path, + ) + output: queue.Queue[str] = queue.Queue() + try: + assert proc.stdin is not None + assert proc.stdout is not None + stdout = proc.stdout + threading.Thread( + target=lambda: output.put(stdout.readline()), + daemon=True, + ).start() + proc.stdin.write(json.dumps({"id": 1, "command": "/tools"}) + "\n") + proc.stdin.flush() + try: + line = output.get(timeout=10) + except queue.Empty: + pytest.fail("slash worker produced no /tools response within 10 seconds") + response = json.loads(line) + assert response["ok"] is True + assert "mcp__profileprobe__hermes_61922_profile_probe" in response["output"] + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) diff --git a/tests/tui_gateway/test_undo_command.py b/tests/tui_gateway/test_undo_command.py index fd1dbca59055..cd0f7e5c4e0b 100644 --- a/tests/tui_gateway/test_undo_command.py +++ b/tests/tui_gateway/test_undo_command.py @@ -43,11 +43,16 @@ def server(hermes_home): ): mod = importlib.import_module("tui_gateway.server") yield mod + # Reset module-level session state without re-importing. importlib.reload + # would re-register the module's atexit hooks; duplicated hooks race the + # stderr buffer at interpreter shutdown (Fatal Python error: + # _enter_buffered_busy) — same class as PR #34217. mod._sessions.clear() mod._pending.clear() mod._answers.clear() - mod._methods.clear() - importlib.reload(mod) + # NOTE: _methods is intentionally NOT cleared — it's populated at import + # time and would only repopulate via reload. + mod._db = None @pytest.fixture() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 911e25204ab6..24c2cd3325d5 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1055,6 +1055,8 @@ def _build_child_agent( override_base_url: Optional[str] = None, override_api_key: Optional[str] = None, override_api_mode: Optional[str] = None, + override_request_overrides: Optional[Dict[str, Any]] = None, + override_max_tokens: Optional[int] = None, # ACP transport overrides from trusted delegation config. override_acp_command: Optional[str] = None, override_acp_args: Optional[List[str]] = None, @@ -1288,16 +1290,33 @@ def _child_thinking(text: str) -> None: child_providers_ignored = getattr(parent_agent, "providers_ignored", None) child_providers_order = getattr(parent_agent, "providers_order", None) child_provider_sort = getattr(parent_agent, "provider_sort", None) + child_provider_require_parameters = getattr( + parent_agent, "provider_require_parameters", False + ) + child_provider_data_collection = getattr( + parent_agent, "provider_data_collection", None + ) or "" child_openrouter_min_coding_score = getattr(parent_agent, "openrouter_min_coding_score", None) if override_provider: child_providers_allowed = None child_providers_ignored = None child_providers_order = None child_provider_sort = None + child_provider_require_parameters = False + child_provider_data_collection = "" # Note: openrouter_min_coding_score is model-gated (only emitted on # openrouter/pareto-code), so we keep it inherited even when the # provider is overridden — it's a no-op on any other model. + child_max_tokens = ( + override_max_tokens + if override_max_tokens is not None + else getattr(parent_agent, "max_tokens", None) + ) + child_optional_kwargs: Dict[str, Any] = {} + if isinstance(child_max_tokens, int): + child_optional_kwargs["max_tokens"] = child_max_tokens + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1307,7 +1326,7 @@ def _child_thinking(text: str) -> None: acp_command=effective_acp_command, acp_args=effective_acp_args, max_iterations=max_iterations, - max_tokens=getattr(parent_agent, "max_tokens", None), + reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), fallback_model=parent_fallback, @@ -1326,9 +1345,17 @@ def _child_thinking(text: str) -> None: providers_ignored=child_providers_ignored, providers_order=child_providers_order, provider_sort=child_provider_sort, + provider_require_parameters=child_provider_require_parameters, + provider_data_collection=child_provider_data_collection, + request_overrides=( + dict(override_request_overrides or {}) + if override_provider + else dict(getattr(parent_agent, "request_overrides", {}) or {}) + ), openrouter_min_coding_score=child_openrouter_min_coding_score, tool_progress_callback=child_progress_cb, iteration_budget=None, # fresh budget per subagent + **child_optional_kwargs, ) child._print_fn = getattr(parent_agent, "_print_fn", None) # Now the child exists, its session id can ride on every relayed event @@ -2500,6 +2527,8 @@ def delegate_task( override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], + override_request_overrides=creds.get("request_overrides"), + override_max_tokens=creds.get("max_output_tokens"), override_acp_command=creds.get("command"), override_acp_args=creds.get("args"), role=effective_role, @@ -3086,6 +3115,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": None, "api_key": None, "api_mode": None, + "request_overrides": None, + "max_output_tokens": None, } # Provider is configured — resolve full credentials @@ -3114,6 +3145,8 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"), + "request_overrides": dict(runtime.get("request_overrides") or {}), + "max_output_tokens": runtime.get("max_output_tokens"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), } diff --git a/tools/environments/local.py b/tools/environments/local.py index 191ff4d4b2da..26d0b2a454c7 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -25,16 +25,24 @@ def _msys_to_windows_path(cwd: str) -> str: native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and ``subprocess.Popen(..., cwd=...)`` can find it. + Also accepts the Cygwin (``/cygdrive/c/...``) and WSL-mount + (``/mnt/c/...``) spellings of a drive root. Multi-segment POSIX paths + like ``/home/x`` or ``/tmp/foo`` are left untouched. + No-ops on non-Windows hosts or for paths that aren't in MSYS form. Returns the input unchanged when no translation applies. This is idempotent — calling it on an already-Windows path returns it as-is. """ if not _IS_WINDOWS or not cwd: return cwd - # Match leading "//" or exactly "/" (bare drive root). - m = re.match(r'^/([a-zA-Z])(/.*)?$', cwd) + # Match leading "//" or exactly "/" (bare drive root), + # plus /cygdrive//... and /mnt//... variants. + m = re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd) if not m: return cwd + # Reject /cygdrive or /mnt with no drive letter — the optional group above + # already requires the letter. Multi-char first segments (/home, /tmp) + # fail the single-letter capture and fall through as no-ops. drive = m.group(1).upper() tail = (m.group(2) or "").replace('/', '\\') return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape diff --git a/tools/file_tools.py b/tools/file_tools.py index 61e6ecc588d5..e602e8e0a675 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -6,6 +6,7 @@ import logging import os import posixpath +import sys import threading from pathlib import Path, PurePosixPath @@ -406,6 +407,17 @@ def _resolve_base_dir( if not posixpath.isabs(base_text): base_text = posixpath.join(os.getcwd(), base_text) return _normalize_without_host_deref(base_text) + # Git Bash ``pwd -P`` reports ``/c/Users/...``; translate before Path so + # relative file-tool paths don't anchor under a nonexistent ``\\c\\Users``. + from tools.environments.local import _msys_to_windows_path + + base_text = _msys_to_windows_path(base_text) + if sys.platform == "win32": + import ntpath + + if not ntpath.isabs(base_text): + base_text = ntpath.join(os.getcwd(), base_text) + return Path(ntpath.normpath(base_text)) base = Path(base_text) if not base.is_absolute(): # Last-resort anchoring: a live cwd should already be absolute, but if a @@ -420,14 +432,31 @@ def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path | Pu See :func:`_resolve_base_dir` for how the base is chosen. Absolute input paths are returned resolved-but-unanchored. + + On native Windows, Git Bash / MSYS drive paths (``/c/Users/...``) are + translated to ``C:\\Users\\...`` before resolution so file tools don't + treat them as relative ``\\c\\Users\\...`` under the process cwd. """ container_paths = _uses_container_paths(task_id) - expanded = _expand_tilde(filepath) if container_paths: + expanded = _expand_tilde(filepath) if posixpath.isabs(expanded): return _normalize_without_host_deref(expanded) resolved = _resolve_base_dir(task_id, container_paths=True) / expanded return _normalize_without_host_deref(resolved) + + # Host paths only — never rewrite Linux paths inside a container/WSL env. + from tools.environments.local import _msys_to_windows_path + + expanded = _expand_tilde(_msys_to_windows_path(filepath)) + if sys.platform == "win32": + import ntpath + + if ntpath.isabs(expanded): + return Path(ntpath.normpath(expanded)) + joined = ntpath.join(str(_resolve_base_dir(task_id, container_paths=False)), expanded) + return Path(ntpath.normpath(joined)) + p = Path(expanded) if p.is_absolute(): return p.resolve() diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 94cd069b8d33..d35feb861cf5 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -188,7 +188,7 @@ "qrcode==7.4.2", ), "platform.feishu": ( - "lark-oapi==1.5.3", + "lark-oapi==1.6.8", "qrcode==7.4.2", ), # WeCom callback-mode adapter — parses untrusted XML POST bodies. Pulls diff --git a/tools/registry.py b/tools/registry.py index 35589bd2c849..9b6611fb407d 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -571,10 +571,43 @@ def get_definitions(self, tool_names: Set[str], quiet: bool = False) -> List[dic # Dispatch # ------------------------------------------------------------------ - def dispatch(self, name: str, args: dict, **kwargs) -> str: + @staticmethod + def _normalize_handler_result(name: str, result): + """Enforce the result shapes supported by the agent tool pipeline. + + Normal tool results are strings. The sole structured exception is the + multimodal envelope consumed by the agent executor. Returning every + other value as a string error keeps logging, hooks, budgeting, and + persistence from receiving values they cannot safely slice or size. + """ + if isinstance(result, str): + return result + if ( + isinstance(result, dict) + and result.get("_multimodal") is True + and isinstance(result.get("content"), list) + ): + return result + + result_type = type(result).__name__ + logger.error( + "Tool %s handler returned unsupported result type: %s", + name, + result_type, + ) + return json.dumps({ + "error": f"Tool handler returned unsupported result type: {result_type}", + "error_type": "tool_result_contract", + "tool": name, + "result_type": result_type, + }, ensure_ascii=False) + + def dispatch(self, name: str, args: dict, **kwargs) -> str | dict: """Execute a tool handler by name. * Async handlers are bridged automatically via ``_run_async()``. + * Handler results are normalized to a string or supported multimodal + envelope before leaving the registry. * All exceptions are caught and returned as ``{"error": "..."}`` for consistent error format. """ @@ -584,8 +617,10 @@ def dispatch(self, name: str, args: dict, **kwargs) -> str: try: if entry.is_async: from model_tools import _run_async - return _run_async(entry.handler(args, **kwargs)) - return entry.handler(args, **kwargs) + result = _run_async(entry.handler(args, **kwargs)) + else: + result = entry.handler(args, **kwargs) + return self._normalize_handler_result(name, result) except Exception as e: logger.exception("Tool %s dispatch error: %s", name, e) # Route through the sanitizer so framing tokens / CDATA / fences diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 49f8cbaca226..959afc9e3531 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -122,7 +122,7 @@ def _load_stt_config() -> dict: """Load the ``stt`` section from user config, falling back to defaults.""" try: from hermes_cli.config import load_config - return load_config().get("stt", {}) + return load_config().get("stt") or {} except Exception: return {} @@ -1130,7 +1130,7 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]: # Language: config.yaml (stt.local.language) > env var > auto-detect. _forced_lang = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or None ) @@ -1213,7 +1213,7 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] # Language: config.yaml (stt.local.language) > env var > "en" default. language = ( - _load_stt_config().get("local", {}).get("language") + (_load_stt_config().get("local") or {}).get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or DEFAULT_LOCAL_STT_LANGUAGE ) @@ -1447,7 +1447,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: } stt_config = _load_stt_config() - xai_config = stt_config.get("xai", {}) + xai_config = stt_config.get("xai") or {} base_url = str( xai_config.get("base_url") or get_env_value("XAI_STT_BASE_URL") @@ -1542,7 +1542,7 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": "ELEVENLABS_API_KEY not set"} stt_config = _load_stt_config() - elevenlabs_config = stt_config.get("elevenlabs", {}) + elevenlabs_config = stt_config.get("elevenlabs") or {} base_url = str( elevenlabs_config.get("base_url") or get_env_value("ELEVENLABS_STT_BASE_URL") @@ -1657,14 +1657,14 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A provider = _get_provider(stt_config) if provider == "local": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) return _transcribe_local(file_path, model_name) if provider == "local_command": - local_cfg = stt_config.get("local", {}) + local_cfg = stt_config.get("local") or {} model_name = _normalize_local_command_model( model or local_cfg.get("model", DEFAULT_LOCAL_MODEL) ) @@ -1675,12 +1675,12 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_groq(file_path, model_name) if provider == "openai": - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} model_name = model or openai_cfg.get("model", DEFAULT_STT_MODEL) return _transcribe_openai(file_path, model_name) if provider == "mistral": - mistral_cfg = stt_config.get("mistral", {}) + mistral_cfg = stt_config.get("mistral") or {} model_name = model or mistral_cfg.get("model", DEFAULT_MISTRAL_STT_MODEL) return _transcribe_mistral(file_path, model_name) @@ -1690,7 +1690,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A return _transcribe_xai(file_path, model_name) if provider == "elevenlabs": - elevenlabs_cfg = stt_config.get("elevenlabs", {}) + elevenlabs_cfg = stt_config.get("elevenlabs") or {} model_name = model or elevenlabs_cfg.get("model_id", DEFAULT_ELEVENLABS_STT_MODEL) return _transcribe_elevenlabs(file_path, model_name) @@ -1754,7 +1754,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A def _resolve_openai_audio_client_config() -> tuple[str, str]: """Return direct OpenAI audio config or a managed gateway fallback.""" stt_config = _load_stt_config() - openai_cfg = stt_config.get("openai", {}) + openai_cfg = stt_config.get("openai") or {} cfg_api_key = openai_cfg.get("api_key", "") cfg_base_url = openai_cfg.get("base_url", "") if cfg_api_key: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index e2a96fb4ad7b..ec7b361b8e14 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -341,7 +341,7 @@ def _load_tts_config() -> Dict[str, Any]: try: from hermes_cli.config import load_config config = load_config() - return config.get("tts", {}) + return config.get("tts") or {} except ImportError: logger.debug("hermes_cli.config not available, using default TTS config") return {} @@ -949,7 +949,7 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str, Path to the saved audio file. """ _edge_tts = _import_edge_tts() - edge_config = tts_config.get("edge", {}) + edge_config = tts_config.get("edge") or {} voice = edge_config.get("voice", DEFAULT_EDGE_VOICE) speed = float(edge_config.get("speed", tts_config.get("speed", 1.0))) @@ -982,7 +982,7 @@ def _generate_elevenlabs(text: str, output_path: str, tts_config: Dict[str, Any] if not api_key: raise ValueError("ELEVENLABS_API_KEY not set. Get one at https://elevenlabs.io/") - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID) model_id = el_config.get("model_id", DEFAULT_ELEVENLABS_MODEL_ID) @@ -1026,7 +1026,7 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] """ api_key, base_url, is_managed = _resolve_openai_audio_client_config() - oai_config = tts_config.get("openai", {}) + oai_config = tts_config.get("openai") or {} model = oai_config.get("model", DEFAULT_OPENAI_MODEL) voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) custom_base_url = oai_config.get("base_url") @@ -1204,7 +1204,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - if not api_key: raise ValueError("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.") - xai_config = tts_config.get("xai", {}) + xai_config = tts_config.get("xai") or {} voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID language = str(xai_config.get("language", DEFAULT_XAI_LANGUAGE)).strip() or DEFAULT_XAI_LANGUAGE sample_rate = int(xai_config.get("sample_rate", DEFAULT_XAI_SAMPLE_RATE)) @@ -1443,7 +1443,7 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any if not api_key: raise ValueError("MISTRAL_API_KEY not set. Get one at https://console.mistral.ai/") - mi_config = tts_config.get("mistral", {}) + mi_config = tts_config.get("mistral") or {} model = mi_config.get("model", DEFAULT_MISTRAL_TTS_MODEL) voice_id = mi_config.get("voice_id") or DEFAULT_MISTRAL_TTS_VOICE_ID @@ -1694,7 +1694,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any] "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey" ) - raw_gemini_config = tts_config.get("gemini", {}) + raw_gemini_config = tts_config.get("gemini") or {} gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {} model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE @@ -1858,7 +1858,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) -> """ import sys - neutts_config = tts_config.get("neutts", {}) + neutts_config = tts_config.get("neutts") or {} ref_audio = neutts_config.get("ref_audio", "") or _default_neutts_ref_audio() ref_text = neutts_config.get("ref_text", "") or _default_neutts_ref_text() model = neutts_config.get("model", "neuphonic/neutts-air-q4-gguf") @@ -1996,7 +1996,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) PiperVoice = _import_piper() import wave - piper_config = tts_config.get("piper", {}) if isinstance(tts_config, dict) else {} + piper_config = tts_config.get("piper") or {} if isinstance(tts_config, dict) else {} voice_name = piper_config.get("voice") or DEFAULT_PIPER_VOICE download_dir = Path(piper_config.get("voices_dir") or _get_piper_voices_dir()).expanduser() download_dir.mkdir(parents=True, exist_ok=True) @@ -2619,7 +2619,7 @@ def stream_tts_to_speaker( model_id = DEFAULT_ELEVENLABS_STREAMING_MODEL_ID tts_config = _load_tts_config() - el_config = tts_config.get("elevenlabs", {}) + el_config = tts_config.get("elevenlabs") or {} voice_id = el_config.get("voice_id", voice_id) model_id = el_config.get("streaming_model_id", el_config.get("model_id", model_id)) diff --git a/tools/web_tools.py b/tools/web_tools.py index 409b46fa606f..7754c386a56f 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -103,6 +103,21 @@ logger = logging.getLogger(__name__) +def _web_extract_url(value: Any) -> Optional[str]: + """Return a usable URL from a model-supplied extract item. + + Models sometimes forward a complete web-search result instead of its URL. + Accept the two common URL keys, but reject missing/non-string values rather + than stringifying arbitrary objects into misleading fetch targets. + """ + if isinstance(value, dict): + value = value.get("url") or value.get("href") + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + # ─── Backend Selection ──────────────────────────────────────────────────────── def _env_value(name: str) -> str: @@ -132,7 +147,11 @@ def _load_web_config() -> dict: """Load the ``web:`` section from ~/.hermes/config.yaml.""" try: from hermes_cli.config import load_config - return load_config().get("web", {}) + # ``or {}``: a present-but-null ``web:`` section (YAML ``web:`` with no + # body) makes ``.get("web", {})`` return None, which would break every + # caller that does ``_load_web_config().get(...)``. Honor the ``-> dict`` + # contract so callers never see None. + return load_config().get("web") or {} except (ImportError, Exception): return {} @@ -722,7 +741,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: async def web_extract_tool( - urls: List[str], + urls: List[Any], format: str = None, char_limit: Optional[int] = None, ) -> str: @@ -738,7 +757,8 @@ async def web_extract_tool( ``[IMAGE: alt]`` placeholders (real image URLs are preserved as links). Args: - urls (List[str]): List of URLs to extract content from + urls (List[Any]): URL strings or search-result objects containing a + string ``url`` or ``href`` field format (str): Desired output format ("markdown" or "html", optional) char_limit (Optional[int]): Per-page char budget sent to the model (default: web.extract_char_limit or 15000). Larger pages truncate. @@ -758,7 +778,21 @@ async def web_extract_tool( from agent.redact import _PREFIX_RE from urllib.parse import unquote normalized_urls: List[str] = [] - for _url in urls: + normalized_indices: List[int] = [] + invalid_urls: Dict[int, Dict[str, Any]] = {} + for index, item in enumerate(urls): + _url = _web_extract_url(item) + if _url is None: + invalid_urls[index] = { + "url": "", + "title": "", + "content": "", + "error": ( + f"Invalid URL item at index {index}: expected a URL string " + "or an object with a string 'url' or 'href' field" + ), + } + continue normalized_url = normalize_url_for_request(_url) if ( _PREFIX_RE.search(_url) @@ -783,6 +817,7 @@ async def web_extract_tool( ), }) normalized_urls.append(normalized_url) + normalized_indices.append(index) debug_call_data = { "parameters": { @@ -804,15 +839,17 @@ async def web_extract_tool( # ── SSRF protection — filter out private/internal URLs before any backend ── safe_urls = [] - ssrf_blocked: List[Dict[str, Any]] = [] - for url in normalized_urls: + safe_indices = [] + ssrf_blocked: Dict[int, Dict[str, Any]] = {} + for index, url in zip(normalized_indices, normalized_urls): if not await async_is_safe_url(url): - ssrf_blocked.append({ + ssrf_blocked[index] = { "url": url, "title": "", "content": "", "error": "Blocked: URL targets a private or internal network address", - }) + } else: safe_urls.append(url) + safe_indices.append(index) # Dispatch only safe URLs to the configured backend if not safe_urls: @@ -906,9 +943,25 @@ async def web_extract_tool( provider.extract, safe_urls, format=format ) - # Merge any SSRF-blocked results back in - if ssrf_blocked: - results = ssrf_blocked + results + # Reconstruct the original input order across invalid, blocked, and + # provider-processed entries. Providers are expected to preserve the + # order of the safe URL list they receive. + if invalid_urls or ssrf_blocked: + safe_results = { + index: ( + results[position] + if position < len(results) + else { + "url": safe_urls[position], + "title": "", + "content": "", + "error": "Extract backend returned no result for this URL", + } + ) + for position, index in enumerate(safe_indices) + } + by_index = {**safe_results, **ssrf_blocked, **invalid_urls} + results = [by_index[index] for index in range(len(urls))] response = {"results": results} @@ -1004,7 +1057,9 @@ def check_web_api_key() -> bool: :func:`_is_backend_available`, which delegates non-legacy names to the registry. """ - configured = _load_web_config().get("backend", "").lower().strip() + # ``or ""``: a null ``web.backend`` value yields None from ``.get``, and + # ``None.lower()`` would raise. Mirrors ``_get_backend``. + configured = (_load_web_config().get("backend") or "").lower().strip() if configured and _is_backend_available(configured): return True # Any built-in backend with credentials present. This is a boolean OR, so diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8e825a22669d..4e2fa3eadeeb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -568,24 +568,21 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No history = list(session.get("history", [])) # ── Persist unflushed messages to SQLite ────────────────────────── - # Two sources, tried in order of freshness: - # 1. agent._session_messages — set by the last _persist_session() - # call inside run_conversation(). This is the most recent - # snapshot the agent thread wrote, and may include partial - # turn data that hasn't reached session["history"] yet. - # 2. session["history"] — updated after run_conversation() - # returns. Stale when the agent is mid‑turn, but correct - # when the turn completed before finalize. - # Best‑effort — the agent thread may still be mid‑turn, so only - # previously completed messages are guaranteed. + # Flush ``agent._session_messages`` via ``_persist_session``'s marker-based + # dedup (same contract as the gateway-shutdown flush, #13121). Do NOT pass + # ``conversation_history``: ``session["history"]`` and ``_session_messages`` + # alias the SAME list once a turn completes, so passing it made + # ``_flush_messages_to_session_db`` treat every message as already-durable + # and skip it — a data-loss bug when finalize is the sole persist path after + # a WS disconnect/restart (e.g. the in-turn flush hit a transient SQLite + # failure). Markers persist the genuinely-unflushed tail without duplicating + # durable rows (including a resumed-but-not-run session's already-in-DB + # transcript, which stays in ``session["history"]`` only). if agent is not None and hasattr(agent, "_persist_session"): - snapshot = ( - getattr(agent, "_session_messages", None) - or history - ) + snapshot = getattr(agent, "_session_messages", None) if snapshot: try: - agent._persist_session(snapshot, conversation_history=history) + agent._persist_session(snapshot) except Exception: pass @@ -3669,6 +3666,19 @@ def _on_tool_progress( # the stable tool id and args. Emitting another id-less progress row # here makes the desktop live view diverge from hydrated history. return + if event_type == "tool.output_risk" and name: + metadata = _kwargs.get("risk_metadata") + if not isinstance(metadata, dict): + return + payload: dict[str, object] = { + "tool_id": str(_kwargs.get("tool_call_id") or ""), + "name": str(name), + "risk": str(metadata.get("risk") or "low"), + "findings": [str(item) for item in metadata.get("findings", [])], + "redacted": bool(metadata.get("redacted", False)), + } + _emit("tool.output_risk", sid, payload) + return if event_type == "reasoning.available" and preview: payload: dict[str, object] = {"text": str(preview)} if _session_verbose(sid): diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py index 00e83bedf148..34cc75953733 100644 --- a/tui_gateway/slash_worker.py +++ b/tui_gateway/slash_worker.py @@ -65,6 +65,27 @@ def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool: return True +def _prepare_slash_worker_runtime() -> None: + """Start bounded MCP discovery before HermesCLI snapshots tools. + + Each slash_worker child is its own process — the parent ``hermes serve`` + discovery thread does not populate this registry (issue #61891). + """ + import logging + + from hermes_cli.mcp_startup import ( + start_background_mcp_discovery, + wait_for_mcp_discovery, + ) + + logger = logging.getLogger(__name__) + start_background_mcp_discovery( + logger=logger, + thread_name="slash-worker-mcp-discovery", + ) + wait_for_mcp_discovery() + + def _start_parent_death_watchdog(original_ppid, parent_create_time) -> None: def _loop(): while not _is_orphaned(original_ppid, parent_create_time): @@ -129,6 +150,7 @@ def main(): except psutil.Error: parent_create_time = 0.0 _start_parent_death_watchdog(orig_ppid, parent_create_time) + _prepare_slash_worker_runtime() with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False) diff --git a/uv.lock b/uv.lock index f70435a95060..21bd0827c77e 100644 --- a/uv.lock +++ b/uv.lock @@ -1794,7 +1794,7 @@ requires-dist = [ { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, + { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" }, { name = "markdown", specifier = "==3.10.2" }, { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, @@ -2184,7 +2184,7 @@ wheels = [ [[package]] name = "lark-oapi" -version = "1.5.3" +version = "1.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2194,7 +2194,7 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ad/1ab04db5d549ad1a7a2cd33682b1c38dee1d65019cb24fd7a23270e6337d/lark_oapi-1.6.8-py3-none-any.whl", hash = "sha256:9b443a5d47a7d204dd42dc40896c8b75087cc35788e45c48c140806d7df7e5e8", size = 7798300, upload-time = "2026-06-02T07:40:05.492Z" }, ] [[package]] diff --git a/web/src/lib/chatImagePaste.test.ts b/web/src/lib/chatImagePaste.test.ts new file mode 100644 index 000000000000..047dec175289 --- /dev/null +++ b/web/src/lib/chatImagePaste.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + firstImageFromClipboard, + imageFilesFromTransfer, + transferMayContainImage, +} from "./chatImagePaste"; + +// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed +// items/files, so we hand-roll the shape the helpers read. +function makeItem(kind: string, type: string, file: File | null) { + return { kind, type, getAsFile: () => file } as unknown as DataTransferItem; +} + +function makeData(opts: { + items?: DataTransferItem[]; + files?: File[]; +}): DataTransfer { + const items = opts.items ?? []; + const files = opts.files ?? []; + const itemList: Record = { length: items.length }; + items.forEach((it, i) => { + itemList[i] = it; + }); + const fileList: Record = { length: files.length }; + files.forEach((f, i) => { + fileList[i] = f; + }); + return { + items: itemList, + files: fileList, + } as unknown as DataTransfer; +} + +const png = new File([new Uint8Array([1, 2, 3])], "x.png", { + type: "image/png", +}); +const gif = new File([new Uint8Array([4, 5])], "y.gif", { + type: "image/gif", +}); + +describe("firstImageFromClipboard", () => { + it("returns null for null clipboard data", () => { + expect(firstImageFromClipboard(null)).toBeNull(); + }); + + it("finds an image via items[].getAsFile()", () => { + const data = makeData({ items: [makeItem("file", "image/png", png)] }); + expect(firstImageFromClipboard(data)).toBe(png); + }); + + it("ignores non-file and non-image items", () => { + const data = makeData({ + items: [ + makeItem("string", "text/plain", null), + makeItem("file", "application/pdf", new File([], "a.pdf")), + ], + }); + expect(firstImageFromClipboard(data)).toBeNull(); + }); + + it("falls back to files[] when items are absent (Safari/Firefox)", () => { + const data = makeData({ files: [png] }); + expect(firstImageFromClipboard(data)).toBe(png); + }); + + it("returns null when nothing image-like is present", () => { + const data = makeData({ + files: [new File([], "notes.txt", { type: "text/plain" })], + }); + expect(firstImageFromClipboard(data)).toBeNull(); + }); +}); + +describe("imageFilesFromTransfer", () => { + it("dedupes the same file when present in both items and files", () => { + const data = makeData({ + items: [makeItem("file", "image/png", png)], + files: [png, gif], + }); + expect(imageFilesFromTransfer(data)).toEqual([png, gif]); + }); +}); + +describe("transferMayContainImage", () => { + it("is true for image items even when type is empty (some browsers)", () => { + const data = makeData({ + items: [makeItem("file", "", png)], + }); + expect(transferMayContainImage(data)).toBe(true); + }); + + it("is false for text-only transfers", () => { + const data = makeData({ + items: [makeItem("string", "text/plain", null)], + }); + expect(transferMayContainImage(data)).toBe(false); + }); +}); diff --git a/web/src/lib/chatImagePaste.ts b/web/src/lib/chatImagePaste.ts new file mode 100644 index 000000000000..3767cf803746 --- /dev/null +++ b/web/src/lib/chatImagePaste.ts @@ -0,0 +1,164 @@ +import { authedFetch } from "@/lib/api"; + +// Clipboard image MIME → file extension. Mirrors the set the TUI's /image +// attach path and the gateway's image sniffer accept. +const IMAGE_MIME_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +}; + +// Anthropic caps a single vision image at ~25 MB; reject earlier client-side +// with a clear message rather than round-tripping a doomed upload. +const MAX_IMAGE_BYTES = 25 * 1024 * 1024; + +export interface ChatImageUploadResult { + /** Absolute path under HERMES_HOME/images the gateway wrote. */ + path: string; + /** Byte size of the uploaded image. */ + bytes: number; + /** Basename written on the server. */ + name: string; + mime_type: string; +} + +function imageFileKey(file: File): string { + return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`; +} + +function addImageFile(files: File[], seen: Set, file: File | null) { + if (!file || !file.type.startsWith("image/")) return; + const key = imageFileKey(file); + if (seen.has(key)) return; + seen.add(key); + files.push(file); +} + +/** Pull every image file out of a DataTransfer (clipboard or drop). */ +export function imageFilesFromTransfer( + data: DataTransfer | null, +): File[] { + if (!data) return []; + const files: File[] = []; + const seen = new Set(); + + if (data.items?.length) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + if (item.kind === "file" && item.type.startsWith("image/")) { + addImageFile(files, seen, item.getAsFile()); + } + } + } + + if (data.files?.length) { + for (let i = 0; i < data.files.length; i++) { + addImageFile(files, seen, data.files[i]); + } + } + + return files; +} + +/** Pull the first image blob out of a DataTransfer, or null if none present. */ +export function firstImageFromClipboard( + data: DataTransfer | null, +): File | null { + return imageFilesFromTransfer(data)[0] ?? null; +} + +/** True when a drag payload may contain an image (for dragover preventDefault). */ +export function transferMayContainImage(data: DataTransfer | null): boolean { + if (!data) return false; + if (data.items?.length) { + for (let i = 0; i < data.items.length; i++) { + const item = data.items[i]; + if ( + item.kind === "file" && + (!item.type || item.type.startsWith("image/")) + ) { + return true; + } + } + return false; + } + if (data.files?.length) { + for (let i = 0; i < data.files.length; i++) { + if (data.files[i].type.startsWith("image/")) return true; + } + } + return false; +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => + reject(reader.error ?? new Error("image read failed")); + reader.onload = () => { + const result = reader.result; + if (typeof result === "string") { + resolve(result); + } else { + reject(new Error("image read failed")); + } + }; + reader.readAsDataURL(file); + }); +} + +/** + * Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the + * dedicated chat upload endpoint and return the absolute gateway path. + * + * The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the + * gateway. The container has no access to the browser's clipboard, so the + * server-side ``clipboard.paste`` path can never see a pasted image. + * Upload the bytes the browser already holds, then hand the path to the + * TUI's ``/image`` command. + */ +export async function uploadChatImage( + blob: Blob, + profile = "", +): Promise { + if (blob.size === 0) throw new Error("clipboard image is empty"); + if (blob.size > MAX_IMAGE_BYTES) { + const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024)); + throw new Error(`image too large (max ${mb} MB)`); + } + + const mime = blob.type || "image/png"; + const ext = IMAGE_MIME_EXT[mime] || "png"; + const filename = + blob instanceof File && blob.name + ? blob.name + : `clipboard.${ext}`; + const file = + blob instanceof File + ? blob + : new File([blob], filename, { type: mime }); + + const dataUrl = await fileToDataUrl(file); + const qs = profile ? `?profile=${encodeURIComponent(profile)}` : ""; + const res = await authedFetch(`/api/chat/image-upload${qs}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data_url: dataUrl, + filename, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(text || `HTTP ${res.status}`); + } + + const uploaded = (await res.json()) as ChatImageUploadResult; + if (!uploaded?.path) { + throw new Error("image upload did not return a path"); + } + return uploaded; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 68f9221aaa4d..dc65af6ca7c8 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -49,6 +49,11 @@ import { normalizePtyMobileInput, shouldTreatInputAsMobileReplacement, } from "@/lib/pty-mobile-input"; +import { + imageFilesFromTransfer, + transferMayContainImage, + uploadChatImage, +} from "@/lib/chatImagePaste"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; @@ -486,7 +491,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // --- Clipboard integration --------------------------------------- // - // Three independent paths all route to the system clipboard: + // Four independent paths all route to the system clipboard: // // 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler // in useInputHandlers.ts turns Ctrl+C into a copy when the @@ -500,9 +505,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // ever stops listening (e.g. overlays / pickers) or if the user // has selected with the mouse outside of Ink's selection model. // - // 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds - // it to the terminal as keyboard input. xterm's paste() wraps - // it with bracketed-paste if the host has that mode enabled. + // 3. **Ctrl/Cmd+Shift+V.** Prefers clipboard.read() for images + // (upload → `/image`), else readText() into term.paste(). + // preventDefault here suppresses the DOM paste event, so image + // handling must live in this key path — not only the host + // listener below. + // + // 4. **DOM paste / drop on the host.** Bare Ctrl+V and context-menu + // paste fire a ClipboardEvent; drag-drop lands files. Image + // payloads upload to HERMES_HOME/images then drive `/image`. // // OSC 52 reads (terminal asking to read the clipboard) are not // supported — that would let any content the TUI renders exfiltrate @@ -532,6 +543,73 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); + // ── Image paste / drop ─────────────────────────────────────────────── + // The Chat tab is an xterm mirror of a TUI inside the gateway. Server-side + // clipboard.paste / xclip never see the browser clipboard, so image paste + // must upload browser bytes to HERMES_HOME/images, then drive `/image` + // over the PTY (same burst-then-Return timing as handleCopyLast). + let imageUploadDisposed = false; + const pasteDelay = () => + new Promise((resolve) => window.setTimeout(resolve, 40)); + const reportImageUploadError = (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.warn("[dashboard chat] image upload failed:", message); + setBanner(`Image upload failed: ${message}`); + }; + const driveImageAttach = async (paths: string[]) => { + for (const path of paths) { + if (imageUploadDisposed) return; + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + setBanner( + "Image uploaded, but chat is not connected — try again.", + ); + return; + } + ws.send(`/image ${path}`); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + const s = wsRef.current; + if (!s || s.readyState !== WebSocket.OPEN) return; + s.send("\r"); + await pasteDelay(); + } + term.focus(); + }; + const uploadAndAttachImages = (files: File[]) => { + if (!files.length) return; + void (async () => { + const paths: string[] = []; + for (const file of files) { + const uploaded = await uploadChatImage(file, scopedProfile); + if (imageUploadDisposed) return; + paths.push(uploaded.path); + } + await driveImageAttach(paths); + })().catch(reportImageUploadError); + }; + const handleBrowserPaste = (ev: ClipboardEvent) => { + const files = imageFilesFromTransfer(ev.clipboardData); + if (!files.length) return; + ev.preventDefault(); + ev.stopPropagation(); + uploadAndAttachImages(files); + }; + const handleBrowserDragOver = (ev: DragEvent) => { + if (!transferMayContainImage(ev.dataTransfer)) return; + ev.preventDefault(); + if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy"; + }; + const handleBrowserDrop = (ev: DragEvent) => { + const files = imageFilesFromTransfer(ev.dataTransfer); + if (!files.length) return; + ev.preventDefault(); + ev.stopPropagation(); + uploadAndAttachImages(files); + }; + host.addEventListener("paste", handleBrowserPaste, { capture: true }); + host.addEventListener("dragover", handleBrowserDragOver, { capture: true }); + host.addEventListener("drop", handleBrowserDrop, { capture: true }); + term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; @@ -563,15 +641,42 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { } if (pasteModifier && ev.key.toLowerCase() === "v") { - navigator.clipboard - .readText() - .then((text) => { - if (text) term.paste(text); - }) - .catch((err) => { - console.warn("[dashboard clipboard] paste failed:", err.message); - }); + // preventDefault suppresses the DOM paste event, so image paste must + // be handled here via clipboard.read() — readText() alone misses + // image-only clipboards (the Discord / #24860 failure mode). ev.preventDefault(); + void (async () => { + try { + const read = navigator.clipboard?.read; + if (typeof read === "function") { + const items = await read.call(navigator.clipboard); + const files: File[] = []; + for (const item of items) { + const type = item.types.find((t) => t.startsWith("image/")); + if (!type) continue; + const blob = await item.getType(type); + const ext = type.split("/")[1]?.split("+")[0] || "png"; + files.push( + new File([blob], `clipboard.${ext}`, { type }), + ); + } + if (files.length) { + uploadAndAttachImages(files); + return; + } + } + } catch { + /* fall through to text paste */ + } + try { + const text = await navigator.clipboard.readText(); + if (text) term.paste(text); + } catch (err) { + const message = + err instanceof Error ? err.message : String(err); + console.warn("[dashboard clipboard] paste failed:", message); + } + })(); return false; } @@ -1020,10 +1125,14 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return () => { unmounting = true; + imageUploadDisposed = true; syncMetricsRef.current = null; onDataDisposable?.dispose(); onResizeDisposable?.dispose(); mobileInputCleanup?.(); + host.removeEventListener("paste", handleBrowserPaste, true); + host.removeEventListener("dragover", handleBrowserDragOver, true); + host.removeEventListener("drop", handleBrowserDrop, true); if (metricsDebounce) clearTimeout(metricsDebounce); window.removeEventListener("resize", scheduleSyncTerminalMetrics); window.visualViewport?.removeEventListener( diff --git a/website/docs/user-guide/features/provider-routing.md b/website/docs/user-guide/features/provider-routing.md index 3dd6e69787e6..ff8a9ef56ccc 100644 --- a/website/docs/user-guide/features/provider-routing.md +++ b/website/docs/user-guide/features/provider-routing.md @@ -1,18 +1,18 @@ --- title: Provider Routing -description: Configure OpenRouter provider preferences to optimize for cost, speed, or quality. +description: Configure OpenRouter or Nous Portal provider preferences to optimize for cost, speed, or quality. sidebar_label: Provider Routing sidebar_position: 7 --- # Provider Routing -When using [OpenRouter](https://openrouter.ai) as your LLM provider, Hermes Agent supports **provider routing** — fine-grained control over which underlying AI providers handle your requests and how they're prioritized. +When using [OpenRouter](https://openrouter.ai) or [Nous Portal](/integrations/nous-portal) as your LLM provider, Hermes Agent supports **provider routing** — fine-grained control over which underlying AI providers handle your requests and how they're prioritized. OpenRouter routes requests to many providers (e.g., Anthropic, Google, AWS Bedrock, Together AI). Provider routing lets you optimize for cost, speed, quality, or enforce specific provider requirements. :::tip -Traffic routed through [Nous Portal](/integrations/nous-portal) still respects per-model routing and priority configs — and Portal subscribers get 10% off token-billed providers. +Traffic routed through Nous Portal respects the same provider preferences — and Portal subscribers get 10% off token-billed providers. ::: ## Configuration @@ -30,7 +30,7 @@ provider_routing: ``` :::info -Provider routing only applies when using OpenRouter. It has no effect with direct provider connections (e.g., connecting directly to the Anthropic API). +Provider routing only applies when using OpenRouter or Nous Portal. It has no effect with direct provider connections (e.g., connecting directly to the Anthropic API). ::: ## Options @@ -52,13 +52,13 @@ provider_routing: ### `only` -Whitelist of provider names. When set, **only** these providers will be used. All others are excluded. +Whitelist of provider slugs. When set, **only** these providers will be used. All others are excluded. Use the lowercase slug shown by OpenRouter for each provider. ```yaml provider_routing: only: - - "Anthropic" - - "Google" + - "anthropic" + - "google" ``` ### `ignore` @@ -68,8 +68,8 @@ Blacklist of provider names. These providers will **never** be used, even if the ```yaml provider_routing: ignore: - - "Together" - - "DeepInfra" + - "together" + - "deepinfra" ``` ### `order` @@ -79,9 +79,9 @@ Explicit priority order. Providers listed first are preferred. Unlisted provider ```yaml provider_routing: order: - - "Anthropic" - - "Google" - - "AWS Bedrock" + - "anthropic" + - "google" + - "amazon-bedrock" ``` ### `require_parameters` @@ -138,7 +138,7 @@ Ensure all requests go through a specific provider for consistency: ```yaml provider_routing: only: - - "Anthropic" + - "anthropic" ``` ### Avoid Specific Providers @@ -148,8 +148,8 @@ Exclude providers you don't want to use (e.g., for data privacy): ```yaml provider_routing: ignore: - - "Together" - - "Lepton" + - "together" + - "lepton" data_collection: "deny" ``` @@ -160,14 +160,14 @@ Try your preferred providers first, fall back to others if unavailable: ```yaml provider_routing: order: - - "Anthropic" - - "Google" + - "anthropic" + - "google" require_parameters: true ``` ## How It Works -Provider routing preferences are passed to the OpenRouter API via the `extra_body.provider` field on every API call. This applies to both: +Provider routing preferences are passed to OpenRouter or Nous Portal on agent chat requests and iteration-limit summaries via the `extra_body.provider` field. (`extra_body` is the OpenAI Python SDK argument; it becomes the top-level `provider` object in the JSON request.) Auxiliary tasks such as compression and title generation are configured independently under `auxiliary..extra_body`. - **CLI mode** — configured in `~/.hermes/config.yaml`, loaded at startup - **Gateway mode** — same config file, loaded when the gateway starts @@ -189,7 +189,7 @@ You can combine multiple options. For example, sort by price but exclude certain ```yaml provider_routing: sort: "price" - ignore: ["Together"] + ignore: ["together"] require_parameters: true data_collection: "deny" ``` @@ -197,8 +197,8 @@ provider_routing: ## Default Behavior -When no `provider_routing` section is configured (the default), OpenRouter uses its own default routing logic, which generally balances cost and availability automatically. +When no `provider_routing` section is configured (the default), the aggregator uses its own default routing logic, which generally balances cost and availability automatically. :::tip Provider Routing vs. Fallback Models -Provider routing controls which **sub-providers within OpenRouter** handle your requests. For automatic failover to an entirely different provider when your primary model fails, see [Fallback Providers](/user-guide/features/fallback-providers). +Provider routing controls which **sub-providers behind OpenRouter or Nous Portal** handle your requests. For automatic failover to an entirely different provider when your primary model fails, see [Fallback Providers](/user-guide/features/fallback-providers). :::