Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10606,8 +10606,11 @@ async def start(self) -> bool:
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)
Expand Down Expand Up @@ -11708,7 +11711,7 @@ async def _platform_reconnect_watcher(self) -> None:
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)
Expand Down Expand Up @@ -12858,6 +12861,22 @@ async def _handler(event):

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
Expand Down
6 changes: 4 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,11 +401,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
Expand Down
14 changes: 12 additions & 2 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,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).
Expand Down Expand Up @@ -2208,7 +2218,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
Expand Down Expand Up @@ -2268,7 +2278,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
Expand Down
36 changes: 24 additions & 12 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@
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__)
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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


Expand Down
37 changes: 37 additions & 0 deletions tests/gateway/test_64674_multiplex_primary_token_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,43 @@ async def _sec():
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):
Expand Down
14 changes: 14 additions & 0 deletions tests/hermes_cli/test_tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,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``
Expand Down
46 changes: 46 additions & 0 deletions tests/tools/test_browser_camofox_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
camofox_scroll,
camofox_snapshot,
camofox_type,
get_camofox_url,
)


Expand All @@ -42,6 +43,51 @@ def test_empty_when_key_blank(self, monkeypatch):
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."""
Expand Down
97 changes: 97 additions & 0 deletions tests/tools/test_browser_camofox_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,103 @@ def _capture_post(url, json=None, timeout=None, headers=None):
class TestConfiguredCamofoxIdentity:
"""Externally managed Camofox sessions can provide their own identity."""

def test_multiplex_scope_identity_wins_over_process_env_and_config(
self, tmp_path, monkeypatch
):
from agent import secret_scope

monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "https://default.example")
monkeypatch.setenv("CAMOFOX_USER_ID", "default-profile-user")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "default-profile-session")
config = {
"browser": {
"camofox": {
"user_id": "secondary-config-user",
"session_key": "secondary-config-session",
}
}
}
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope(
{
"CAMOFOX_URL": "https://secondary.example",
"CAMOFOX_USER_ID": "secondary-scope-user",
"CAMOFOX_SESSION_KEY": "secondary-scope-session",
}
)
try:
with (
patch("tools.browser_camofox.load_config", return_value=config),
patch(
"tools.browser_camofox.requests.post",
return_value=_mock_response(json_data={"tabId": "scoped-tab"}),
) as mock_post,
):
result = json.loads(
camofox_navigate("https://example.com", task_id="scoped-precedence")
)
request_url = mock_post.call_args.args[0]
request_body = mock_post.call_args.kwargs["json"]
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)

assert result["success"] is True
assert request_url == "https://secondary.example/tabs"
assert request_body["userId"] == "secondary-scope-user"
assert request_body["listItemId"] == "secondary-scope-session"

def test_multiplex_scope_miss_uses_profile_config_not_process_env(
self, tmp_path, monkeypatch
):
from agent import secret_scope

monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_USER_ID", "default-profile-user")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "default-profile-session")
config = {
"browser": {
"camofox": {
"user_id": "secondary-config-user",
"session_key": "secondary-config-session",
}
}
}
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({})
try:
with patch("tools.browser_camofox.load_config", return_value=config):
session = _get_session("config-fallback")
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)

assert session["user_id"] == "secondary-config-user"
assert session["session_key"] == "secondary-config-session"

def test_multiplex_scope_miss_without_config_ignores_process_identity(
self, tmp_path, monkeypatch
):
from agent import secret_scope

monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_USER_ID", "default-profile-user")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "default-profile-session")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({})
try:
with patch("tools.browser_camofox.load_config", return_value={}):
session = _get_session("fail-closed")
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)

assert session["user_id"].startswith("hermes_")
assert session["user_id"] != "default-profile-user"
assert session["session_key"] == "task_fail-closed"
assert session["managed"] is False

def test_env_identity_overrides_default_identity(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
Expand Down
Loading
Loading