From 76cf19fee1f470061c4364321fdca30184f212e5 Mon Sep 17 00:00:00 2001 From: tachyon-r <291518778+tachyon-r@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:36:49 -0400 Subject: [PATCH] fix(tools): isolate model tools by multiplex profile --- gateway/run.py | 25 +++- gateway/session.py | 6 +- hermes_cli/tools_config.py | 14 ++- model_tools.py | 36 ++++-- ...est_64674_multiplex_primary_token_scope.py | 37 ++++++ tests/hermes_cli/test_tools_config.py | 14 +++ tests/tools/test_browser_camofox_auth.py | 46 +++++++ tests/tools/test_discord_tool.py | 58 +++++++++ tests/tools/test_homeassistant_tool.py | 34 +++++ .../tools/test_terminal_tool_requirements.py | 119 ++++++++++++++++++ tools/browser_camofox.py | 5 +- tools/discord_tool.py | 6 +- tools/homeassistant_tool.py | 11 +- tools/registry.py | 73 +++++++++-- 14 files changed, 448 insertions(+), 36 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index a00af86cf6b82..8f7c0f908ced4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10639,8 +10639,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.warning("No adapter available for %s", _pval) continue - # Set up message + fatal error handlers - adapter.set_message_handler(self._handle_message) + # Set up message + fatal error handlers. Under multiplexing the + # default profile needs the same whole-handler runtime scope as a + # secondary profile: authorization and prompt rendering both run + # before the narrower agent-turn scope is installed. + adapter.set_message_handler(self._primary_message_handler()) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) adapter.set_busy_session_handler(self._handle_active_session_busy_message) @@ -11741,7 +11744,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew del self._failed_platforms[platform] continue - adapter.set_message_handler(self._handle_message) + adapter.set_message_handler(self._primary_message_handler()) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) adapter.set_busy_session_handler(self._handle_active_session_busy_message) @@ -12891,6 +12894,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return _handler + def _make_default_profile_message_handler(self): + """Scope a multiplexed default-profile message from ingress onward.""" + profile_home = Path(get_hermes_home()) + + async def _handler(event): + with _profile_runtime_scope(profile_home): + return await self._handle_message(event) + + return _handler + + def _primary_message_handler(self): + """Return the correctly scoped handler for a primary adapter.""" + if getattr(self.config, "multiplex_profiles", False): + return self._make_default_profile_message_handler() + return self._handle_message + @staticmethod def _adapter_credential_claim( platform: Platform, adapter: Any diff --git a/gateway/session.py b/gateway/session.py index ff086fe5badd3..2d5cebd32026c 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -416,11 +416,13 @@ def _discord_tools_loaded() -> bool: Returns False (safe default — keeps the stale-API disclaimer) on any error so a bad config can't silently promise tools the agent lacks. """ - if not (os.environ.get("DISCORD_BOT_TOKEN") or "").strip(): - return False try: + from agent.secret_scope import get_secret from hermes_cli.config import load_config from hermes_cli.tools_config import _get_platform_tools + + if not (get_secret("DISCORD_BOT_TOKEN", "") or "").strip(): + return False cfg = load_config() enabled = _get_platform_tools(cfg, "discord", include_default_mcp_servers=False) return "discord" in enabled or "discord_admin" in enabled diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index d36a38685f073..257eb0b72f6b8 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -191,6 +191,16 @@ def _xai_credentials_present() -> bool: pass return bool(str(os.environ.get("XAI_API_KEY") or "").strip()) + +def _homeassistant_credentials_present() -> bool: + """Return whether the active profile has a Home Assistant token.""" + try: + from agent.secret_scope import get_secret + + return bool((get_secret("HASS_TOKEN", "") or "").strip()) + except Exception: + return False + # Platform-scoped toolsets: only appear in the `hermes tools` checklist for # these platforms, and only resolve/save for these platforms. A toolset # absent from this map is available on every platform (current behaviour). @@ -2282,7 +2292,7 @@ def _get_platform_tools( default_off = set(_DEFAULT_OFF_TOOLSETS) if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS: default_off.remove(platform) - if "homeassistant" in default_off and os.getenv("HASS_TOKEN"): + if "homeassistant" in default_off and _homeassistant_credentials_present(): default_off.remove("homeassistant") _exempt_explicit_platform_native( default_off, platform, explicitly_configured=explicitly_configured @@ -2344,7 +2354,7 @@ def _get_platform_tools( # (e.g. cron) that run through _get_platform_tools without an # explicit saved toolset list. Without this, Norbert's HA cron jobs # regressed after #14798 made cron honor per-platform tool config. - if "homeassistant" in default_off and os.getenv("HASS_TOKEN"): + if "homeassistant" in default_off and _homeassistant_credentials_present(): default_off.remove("homeassistant") # Symmetric carve-out for x_search auto-enable (see the inject # block above). Without this, the default_off subtraction would diff --git a/model_tools.py b/model_tools.py index 9c354194b85fb..40a8f83b3ec88 100644 --- a/model_tools.py +++ b/model_tools.py @@ -29,7 +29,13 @@ import threading import time from typing import Dict, Any, List, Optional, Tuple -from tools.registry import discover_builtin_tools, registry, tool_error +from tools.registry import ( + CHECK_FN_CACHE_BYPASS, + check_fn_cache_scope, + discover_builtin_tools, + registry, + tool_error, +) from toolsets import resolve_toolset, validate_toolset logger = logging.getLogger(__name__) @@ -317,6 +323,7 @@ def get_tool_definitions( # user-visible config edits that affect dynamic schemas (execute_code # mode, discord action allowlist, etc.) without needing an explicit # invalidate hook on every config-writer. + cache_key = None if quiet_mode: try: from hermes_cli.config import get_config_path @@ -325,16 +332,19 @@ def get_tool_definitions( cfg_fp = (cfg_stat.st_mtime_ns, cfg_stat.st_size) except (FileNotFoundError, OSError, ImportError): cfg_fp = None - cache_key = ( - frozenset(enabled_toolsets) if enabled_toolsets is not None else None, - frozenset(disabled_toolsets) if disabled_toolsets else None, - registry._generation, - cfg_fp, - bool(os.environ.get("HERMES_KANBAN_TASK")), - bool(skip_tool_search_assembly), - _is_delegated_child_context(), - ) - cached = _tool_defs_cache.get(cache_key) + profile_scope = check_fn_cache_scope() + if profile_scope != CHECK_FN_CACHE_BYPASS: + cache_key = ( + frozenset(enabled_toolsets) if enabled_toolsets is not None else None, + frozenset(disabled_toolsets) if disabled_toolsets else None, + registry._generation, + cfg_fp, + bool(os.environ.get("HERMES_KANBAN_TASK")), + bool(skip_tool_search_assembly), + _is_delegated_child_context(), + profile_scope, + ) + cached = _tool_defs_cache.get(cache_key) if cache_key is not None else None if cached is not None: # Update _last_resolved_tool_names so downstream callers see # consistent state even on a cache hit. @@ -346,7 +356,7 @@ def get_tool_definitions( result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode, skip_tool_search_assembly=skip_tool_search_assembly) - if quiet_mode: + if quiet_mode and cache_key is not None: # Cache the freshly-computed list, but hand callers a shallow copy so # downstream mutations (e.g. run_agent appending memory/LCM tool # schemas to self.tools) don't poison the cache. Without this, a @@ -361,6 +371,8 @@ def get_tool_definitions( _tool_defs_cache.pop(next(iter(_tool_defs_cache))) # evict oldest _tool_defs_cache[cache_key] = result return list(result) + if quiet_mode: + return list(result) return result diff --git a/tests/gateway/test_64674_multiplex_primary_token_scope.py b/tests/gateway/test_64674_multiplex_primary_token_scope.py index f15418fc4ed99..1ce5f91abdf27 100644 --- a/tests/gateway/test_64674_multiplex_primary_token_scope.py +++ b/tests/gateway/test_64674_multiplex_primary_token_scope.py @@ -119,6 +119,43 @@ class TestPrimaryStartupSkipsEmptyTokenUnderMultiplex: assert created == [] +class TestPrimaryMessageRuntimeScope: + @pytest.mark.asyncio + async def test_default_profile_prompt_gate_sees_its_scoped_token( + self, tmp_path, monkeypatch + ): + from agent import secret_scope + from gateway import run as run_mod + from gateway.run import GatewayRunner + + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text( + "DISCORD_BOT_TOKEN=default-profile-token\n", encoding="utf-8" + ) + (home / "config.yaml").write_text( + "platform_toolsets:\n discord:\n - discord\n", encoding="utf-8" + ) + monkeypatch.setattr(run_mod, "get_hermes_home", lambda: home) + monkeypatch.setenv("DISCORD_BOT_TOKEN", "wrong-process-token") + secret_scope.set_multiplex_active(True) + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + + async def _handle_message(_event): + from gateway.session import _discord_tools_loaded + + return _discord_tools_loaded() + + runner._handle_message = _handle_message # type: ignore[method-assign] + handler = runner._primary_message_handler() + + assert await handler(SimpleNamespace(source=SimpleNamespace(profile=None))) is True + with pytest.raises(secret_scope.UnscopedSecretError): + secret_scope.get_secret("DISCORD_BOT_TOKEN") + + class TestReconnectDropsEmptyToken: @pytest.mark.asyncio async def test_empty_token_removed_from_queue(self): diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 18902ebd589ec..09af0c4e117a4 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -100,6 +100,20 @@ def test_get_platform_tools_homeassistant_toolset_enabled_for_cron_when_hass_tok assert "homeassistant" in cli_enabled +def test_get_platform_tools_homeassistant_uses_active_profile_token(monkeypatch): + from agent import secret_scope + + monkeypatch.delenv("HASS_TOKEN", raising=False) + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"HASS_TOKEN": "profile-token"}) + try: + assert "homeassistant" in _get_platform_tools({}, "cron") + assert "homeassistant" in _get_platform_tools({}, "cli") + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + # ─── #35527: platform-restricted default-off toolsets (discord/discord_admin) # are stripped by _DEFAULT_OFF_TOOLSETS even when the user explicitly opts in # via the platform's native composite. The composite ``hermes-discord`` diff --git a/tests/tools/test_browser_camofox_auth.py b/tests/tools/test_browser_camofox_auth.py index 39b31d0b35a62..059e587858d42 100644 --- a/tests/tools/test_browser_camofox_auth.py +++ b/tests/tools/test_browser_camofox_auth.py @@ -18,6 +18,7 @@ from tools.browser_camofox import ( camofox_scroll, camofox_snapshot, camofox_type, + get_camofox_url, ) @@ -42,6 +43,51 @@ class TestAuthHeaders: monkeypatch.setenv("CAMOFOX_API_KEY", " ") assert _auth_headers() == {} + def test_multiplex_scope_key_wins_over_process_environment(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("CAMOFOX_API_KEY", "default-profile-key") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"CAMOFOX_API_KEY": "secondary-profile-key"}) + try: + assert _auth_headers() == {"Authorization": "Bearer secondary-profile-key"} + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + def test_multiplex_scope_missing_key_fails_closed(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("CAMOFOX_API_KEY", "default-profile-key") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({}) + try: + assert _auth_headers() == {} + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + def test_multiplex_scope_keeps_endpoint_and_key_in_same_profile(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("CAMOFOX_URL", "https://default.example") + monkeypatch.setenv("CAMOFOX_API_KEY", "default-profile-key") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope( + { + "CAMOFOX_URL": "https://secondary.example/", + "CAMOFOX_API_KEY": "secondary-profile-key", + } + ) + try: + assert get_camofox_url() == "https://secondary.example" + assert _auth_headers() == { + "Authorization": "Bearer secondary-profile-key" + } + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + class TestAuthHeadersSent: """Verify all HTTP call sites include auth headers when CAMOFOX_API_KEY is set.""" diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index eaac107c3831d..8de83382abe2c 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -58,6 +58,64 @@ class TestCheckRequirements: monkeypatch.setenv("DISCORD_BOT_TOKEN", " my-token ") assert _get_bot_token() == "my-token" + def test_multiplex_scope_token_wins_over_process_environment(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "another-profile-token") + secret_scope.set_multiplex_active(True) + scope_token = secret_scope.set_secret_scope( + {"DISCORD_BOT_TOKEN": " active-profile-token "} + ) + try: + assert _get_bot_token() == "active-profile-token" + assert check_discord_tool_requirements() is True + finally: + secret_scope.reset_secret_scope(scope_token) + secret_scope.set_multiplex_active(False) + + def test_multiplex_scope_missing_token_fails_closed(self, monkeypatch): + from agent import secret_scope + from tools.registry import invalidate_check_fn_cache, registry + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "another-profile-token") + secret_scope.set_multiplex_active(True) + scope_token = secret_scope.set_secret_scope({"UNRELATED_SECRET": "value"}) + invalidate_check_fn_cache() + try: + assert _get_bot_token() is None + assert check_discord_tool_requirements() is False + assert registry.get_definitions({"discord", "discord_admin"}) == [] + finally: + invalidate_check_fn_cache() + secret_scope.reset_secret_scope(scope_token) + secret_scope.set_multiplex_active(False) + + def test_non_multiplex_scope_miss_keeps_environment_compatibility(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "process-token") + secret_scope.set_multiplex_active(False) + scope_token = secret_scope.set_secret_scope({"UNRELATED_SECRET": "value"}) + try: + assert _get_bot_token() == "process-token" + assert check_discord_tool_requirements() is True + finally: + secret_scope.reset_secret_scope(scope_token) + + def test_gateway_tool_prompt_gate_uses_active_profile_token(self, monkeypatch): + from agent import secret_scope + from gateway.session import _discord_tools_loaded + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "another-profile-token") + secret_scope.set_multiplex_active(True) + scope_token = secret_scope.set_secret_scope({"UNRELATED_SECRET": "value"}) + try: + with patch("hermes_cli.config.load_config") as load_config: + assert _discord_tools_loaded() is False + load_config.assert_not_called() + finally: + secret_scope.reset_secret_scope(scope_token) + secret_scope.set_multiplex_active(False) # --------------------------------------------------------------------------- # Channel type names diff --git a/tests/tools/test_homeassistant_tool.py b/tests/tools/test_homeassistant_tool.py index 3bfb486cdaf94..aab0db77d34d7 100644 --- a/tests/tools/test_homeassistant_tool.py +++ b/tests/tools/test_homeassistant_tool.py @@ -308,6 +308,40 @@ class TestCheckAvailable: monkeypatch.setenv("HASS_TOKEN", "") assert _check_ha_available() is False + def test_multiplex_scope_does_not_fall_back_to_another_profile(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("HASS_TOKEN", "default-profile-token") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({}) + try: + assert _check_ha_available() is False + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + def test_multiplex_scope_supplies_profile_url_and_token(self, monkeypatch): + from agent import secret_scope + from tools.homeassistant_tool import _get_config + + monkeypatch.setattr("tools.homeassistant_tool._HASS_URL", "") + monkeypatch.setattr("tools.homeassistant_tool._HASS_TOKEN", "") + monkeypatch.setenv("HASS_URL", "http://default-profile:8123") + monkeypatch.setenv("HASS_TOKEN", "default-profile-token") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({ + "HASS_URL": "http://secondary-profile:8123/", + "HASS_TOKEN": "secondary-profile-token", + }) + try: + assert _get_config() == ( + "http://secondary-profile:8123", + "secondary-profile-token", + ) + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + # --------------------------------------------------------------------------- # Auth headers diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py index 326a6f18c0a5c..b87bca9da446d 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -134,6 +134,125 @@ class TestCheckFnTransientFailureSuppression: t["now"] += reg._CHECK_FN_FAILURE_GRACE_SECONDS + 1 assert reg._check_fn_cached(probe) is False + def test_profile_scoped_availability_does_not_cross_multiplex_profiles( + self, tmp_path + ): + """Both availability caches must use the active profile as a key.""" + import tools.registry as reg + from agent.secret_scope import ( + get_secret, + reset_secret_scope, + set_multiplex_active, + set_secret_scope, + ) + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + from model_tools import _clear_tool_defs_cache, get_tool_definitions + + profile_a = tmp_path / "profiles" / "a" + profile_b = tmp_path / "profiles" / "b" + profile_a.mkdir(parents=True) + profile_b.mkdir(parents=True) + tool_name = "profile_scoped_availability_probe" + + def probe(): + return bool(get_secret("PROFILE_CACHE_TEST_TOKEN")) + + reg.registry.register( + name=tool_name, + toolset="profile-cache-test", + schema={ + "name": tool_name, + "description": "test-only profile-scoped availability probe", + "parameters": {"type": "object", "properties": {}}, + }, + handler=lambda _args: "ok", + check_fn=probe, + ) + set_multiplex_active(True) + try: + home_a = set_hermes_home_override(str(profile_a)) + secrets_a = set_secret_scope({"PROFILE_CACHE_TEST_TOKEN": "token-a"}) + try: + tools_a = get_tool_definitions( + enabled_toolsets=["profile-cache-test"], + quiet_mode=True, + skip_tool_search_assembly=True, + ) + finally: + reset_secret_scope(secrets_a) + reset_hermes_home_override(home_a) + + home_b = set_hermes_home_override(str(profile_b)) + secrets_b = set_secret_scope({}) + try: + tools_b = get_tool_definitions( + enabled_toolsets=["profile-cache-test"], + quiet_mode=True, + skip_tool_search_assembly=True, + ) + finally: + reset_secret_scope(secrets_b) + reset_hermes_home_override(home_b) + + assert tool_name in {tool["function"]["name"] for tool in tools_a} + assert tool_name not in {tool["function"]["name"] for tool in tools_b} + finally: + set_multiplex_active(False) + reg.registry.deregister(tool_name) + reg.invalidate_check_fn_cache() + _clear_tool_defs_cache() + + def test_unscoped_multiplex_request_bypasses_cache(self, monkeypatch): + """An unknown profile must never share another request's cache entry.""" + import model_tools + import tools.registry as reg + from agent.secret_scope import set_multiplex_active + + values = iter([True, False]) + definition_calls = {"n": 0} + + def probe(): + return next(values) + + def compute_definitions(*_args, **_kwargs): + definition_calls["n"] += 1 + return [] + + set_multiplex_active(True) + monkeypatch.setattr(model_tools, "_compute_tool_definitions", compute_definitions) + try: + assert reg._check_fn_cached(probe) is True + assert reg._check_fn_cached(probe) is False + assert not reg._check_fn_cache + + model_tools.get_tool_definitions(quiet_mode=True) + model_tools.get_tool_definitions(quiet_mode=True) + assert definition_calls["n"] == 2 + assert not model_tools._tool_defs_cache + finally: + set_multiplex_active(False) + reg.invalidate_check_fn_cache() + model_tools._clear_tool_defs_cache() + + def test_profile_scoped_check_cache_is_bounded(self, monkeypatch): + """Many multiplex profiles must not grow registry caches forever.""" + import tools.registry as reg + + scopes = iter(f"/profiles/{index}" for index in range(1_000)) + monkeypatch.setattr(reg, "check_fn_cache_scope", lambda: next(scopes)) + + def available(): + return True + + for _ in range(1_000): + assert reg._check_fn_cached(available) is True + + assert len(reg._check_fn_cache) <= reg._CHECK_FN_CACHE_MAX + assert len(reg._check_fn_last_good) <= reg._CHECK_FN_CACHE_MAX + def test_subagent_keeps_file_tools_through_docker_flake(self, monkeypatch): """End-to-end: a docker probe that flakes on the 2nd build keeps the file/terminal toolset available for the subagent being constructed.""" diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 4151d3cdb1534..4b98f41aff5d5 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -36,6 +36,7 @@ from urllib.parse import SplitResult, urlsplit, urlunsplit import requests +from agent.secret_scope import get_secret from hermes_cli.config import cfg_get, load_config, read_raw_config from tools.browser_camofox_state import get_camofox_identity from tools.registry import tool_error @@ -82,7 +83,7 @@ def _get_command_timeout() -> int: def _auth_headers() -> Dict[str, str]: """Return Authorization header when CAMOFOX_API_KEY is set.""" - key = os.getenv("CAMOFOX_API_KEY", "").strip() + key = (get_secret("CAMOFOX_API_KEY", "") or "").strip() if key: return {"Authorization": f"Bearer {key}"} return {} @@ -90,7 +91,7 @@ def _auth_headers() -> Dict[str, str]: def get_camofox_url() -> str: """Return the configured Camofox server URL, or empty string.""" - return os.getenv("CAMOFOX_URL", "").rstrip("/") + return (get_secret("CAMOFOX_URL", "") or "").rstrip("/") def _config_cdp_url() -> str: diff --git a/tools/discord_tool.py b/tools/discord_tool.py index 822dfa0c670d5..db979a21d87d2 100644 --- a/tools/discord_tool.py +++ b/tools/discord_tool.py @@ -27,13 +27,13 @@ actionable guidance the model can relay to the user. import json import logging -import os import threading import urllib.error import urllib.parse import urllib.request from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from agent.secret_scope import get_secret from tools.registry import registry, tool_error if TYPE_CHECKING: @@ -72,8 +72,8 @@ def _read_limited_response_body(source: Any, limit: int, *, label: str) -> bytes def _get_bot_token() -> Optional[str]: - """Resolve the Discord bot token from environment.""" - return os.getenv("DISCORD_BOT_TOKEN", "").strip() or None + """Resolve the Discord bot token under the active profile secret scope.""" + return (get_secret("DISCORD_BOT_TOKEN", "") or "").strip() or None def _discord_request( diff --git a/tools/homeassistant_tool.py b/tools/homeassistant_tool.py index 27ff985afe51a..771387b4d2677 100644 --- a/tools/homeassistant_tool.py +++ b/tools/homeassistant_tool.py @@ -13,10 +13,11 @@ The HA instance URL is read from ``HASS_URL`` (default: http://homeassistant.loc import asyncio import json import logging -import os import re from typing import Any, Dict, Optional +from agent.secret_scope import get_secret + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -29,10 +30,10 @@ _HASS_TOKEN: str = "" def _get_config(): - """Return (hass_url, hass_token) from env vars at call time.""" + """Return the active profile's Home Assistant URL and token.""" return ( - (_HASS_URL or os.getenv("HASS_URL", "http://homeassistant.local:8123")).rstrip("/"), - _HASS_TOKEN or os.getenv("HASS_TOKEN", ""), + (_HASS_URL or get_secret("HASS_URL", "http://homeassistant.local:8123") or "").rstrip("/"), + _HASS_TOKEN or get_secret("HASS_TOKEN", "") or "", ) # Regex for valid HA entity_id format (e.g. "light.living_room", "sensor.temperature_1") @@ -343,7 +344,7 @@ def _handle_list_services(args: dict, **kw) -> str: def _check_ha_available() -> bool: """Tool is only available when HASS_TOKEN is set.""" - return bool(os.getenv("HASS_TOKEN")) + return bool(get_secret("HASS_TOKEN")) # --------------------------------------------------------------------------- diff --git a/tools/registry.py b/tools/registry.py index fb760b519e9ae..e66d4053cec29 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -218,10 +218,54 @@ _CHECK_FN_TTL_SECONDS = 30.0 # as a flake (last-good True is served) rather than a real outage. Kept short # so a genuinely-down backend is reflected within a couple of turns. _CHECK_FN_FAILURE_GRACE_SECONDS = 60.0 -_check_fn_cache: Dict[Callable, tuple[float, bool]] = {} +_CHECK_FN_CACHE_MAX = 512 +_check_fn_cache: Dict[tuple[Callable, Optional[str]], tuple[float, bool]] = {} # Monotonic timestamp of the most recent True result per check_fn. -_check_fn_last_good: Dict[Callable, float] = {} +_check_fn_last_good: Dict[tuple[Callable, Optional[str]], float] = {} _check_fn_cache_lock = threading.Lock() +CHECK_FN_CACHE_BYPASS = "" + + +def _prune_check_fn_caches(now: float) -> None: + """Expire stale entries and cap profile-dimensional cache growth. + + Caller must hold ``_check_fn_cache_lock``. + """ + for key, (timestamp, _) in list(_check_fn_cache.items()): + if now - timestamp >= _CHECK_FN_TTL_SECONDS: + _check_fn_cache.pop(key, None) + for key, timestamp in list(_check_fn_last_good.items()): + if now - timestamp >= _CHECK_FN_FAILURE_GRACE_SECONDS: + _check_fn_last_good.pop(key, None) + while len(_check_fn_cache) >= _CHECK_FN_CACHE_MAX: + _check_fn_cache.pop(next(iter(_check_fn_cache))) + while len(_check_fn_last_good) >= _CHECK_FN_CACHE_MAX: + _check_fn_last_good.pop(next(iter(_check_fn_last_good))) + + +def check_fn_cache_scope() -> Optional[str]: + """Return the active profile key when availability is profile-scoped. + + Single-profile processes intentionally keep the historical process-wide + cache. A multiplex gateway installs a Hermes-home override for every + profile turn, so the canonical profile key is the stable isolation + boundary across repeated turns for that profile. + """ + try: + from agent.secret_scope import is_multiplex_active + + if not is_multiplex_active(): + return None + from hermes_constants import get_hermes_home_override + + override = get_hermes_home_override() + if not override: + return CHECK_FN_CACHE_BYPASS + return str(Path(override).expanduser().resolve()) + except Exception: + # Fail closed: bypass both cache layers rather than aliasing requests + # whose multiplex profile identity could not be resolved. + return CHECK_FN_CACHE_BYPASS def _check_fn_cached(fn: Callable) -> bool: @@ -234,8 +278,22 @@ def _check_fn_cached(fn: Callable) -> bool: contention, probe timeout) from silently stripping tools mid-session. """ now = time.monotonic() + scope = check_fn_cache_scope() + if scope == CHECK_FN_CACHE_BYPASS: + try: + return bool(fn()) + except Exception: + logger.warning( + "check_fn %s raised while profile cache scope was unresolved; " + "dependent tools will be unavailable this turn", + getattr(fn, "__qualname__", fn), + exc_info=True, + ) + return False + cache_key = (fn, scope) with _check_fn_cache_lock: - cached = _check_fn_cache.get(fn) + _prune_check_fn_caches(now) + cached = _check_fn_cache.get(cache_key) if cached is not None: ts, value = cached if now - ts < _CHECK_FN_TTL_SECONDS: @@ -249,12 +307,13 @@ def _check_fn_cached(fn: Callable) -> bool: raised = True with _check_fn_cache_lock: + _prune_check_fn_caches(now) if value: - _check_fn_last_good[fn] = now - _check_fn_cache[fn] = (now, True) + _check_fn_last_good[cache_key] = now + _check_fn_cache[cache_key] = (now, True) return True - last_good = _check_fn_last_good.get(fn) + last_good = _check_fn_last_good.get(cache_key) if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS: # Recent success → treat this failure as a flake. Serve last-good # True and do NOT cache the failure, so the next call re-probes @@ -275,7 +334,7 @@ def _check_fn_cached(fn: Callable) -> bool: getattr(fn, "__qualname__", fn), "raised" if raised else "returned False", ) - _check_fn_cache[fn] = (now, False) + _check_fn_cache[cache_key] = (now, False) return False