diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1bf3385931..944e3ac6ef 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1048,7 +1048,7 @@ def _nous_min_key_ttl_seconds() -> int: def _scoped_key_env(name: str) -> str: - """Read a provider API key env var through the profile secret scope. + """Read a provider API key (or its paired base-URL) env var through the profile secret scope. In agent turns the scope's verdict is authoritative (a scoped miss must not borrow another profile's key); unscoped startup/CLI paths fall back to os.environ. @@ -1971,8 +1971,8 @@ def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]: ).strip() _url = lambda v: str(v or "").strip().rstrip("/") # noqa: E731 base_url = _xai_validate_inference_base_url( - _url(os.getenv("HERMES_XAI_BASE_URL", "")) - or _url(os.getenv("XAI_BASE_URL", "")) + _url(_scoped_key_env("HERMES_XAI_BASE_URL")) + or _url(_scoped_key_env("XAI_BASE_URL")) or _url(getattr(entry, "runtime_base_url", None)) or _url(getattr(entry, "base_url", None)), fallback=DEFAULT_XAI_OAUTH_BASE_URL, @@ -2246,7 +2246,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: _mark_provider_unhealthy("nous", ttl=60) return None, None base_url = str( - (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) + (nous or {}).get("inference_base_url") or _scoped_key_env("NOUS_INFERENCE_BASE_URL") or _NOUS_DEFAULT_BASE_URL ).rstrip("/") lane = "vision" if vision else "text" # The free tier's host serves exactly one model, for every lane: asking it for the Portal's @@ -2632,7 +2632,8 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[st logger.debug("Auxiliary client: custom runtime resolution failed: %s", exc) runtime = None if not isinstance(runtime, dict): - openai_base = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") + # Base URL is per-profile like the key one line below (a scoped key must not hit the default's proxy). + openai_base = _scoped_key_env("OPENAI_BASE_URL").rstrip("/") if not openai_base: return None, None, None runtime = {"base_url": openai_base, "api_key": _scoped_key_env("OPENAI_API_KEY")} @@ -5605,7 +5606,7 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) from hermes_cli.runtime_provider import _get_named_custom_provider if _get_named_custom_provider(prov) is not None: return prov, existing_base - return "custom", existing_base or os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") or target_base + return "custom", existing_base or _scoped_key_env("OPENAI_BASE_URL").rstrip("/") or target_base def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: diff --git a/agent/i18n.py b/agent/i18n.py index 02f0c97036..03a549c8c3 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -118,9 +118,11 @@ def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None: out[prefix] = node -@lru_cache(maxsize=1) -def _config_language_cached() -> str | None: - """``display.language`` from config.yaml, read once per process (``t()`` is a hot path).""" +@lru_cache(maxsize=8) +def _config_language_cached(hermes_home: str) -> str | None: + """``display.language`` from config.yaml, read once per profile home (``t()`` is a hot path). + Keyed by home so a multiplexed gateway serving several profiles doesn't freeze the first + profile's language for every other profile.""" try: from hermes_cli.config import load_config_readonly lang = (load_config_readonly().get("display") or {}).get("language") @@ -130,6 +132,11 @@ def _config_language_cached() -> str | None: return None +def _config_language() -> str | None: + from hermes_constants import get_hermes_home + return _config_language_cached(str(get_hermes_home())) + + def reset_language_cache() -> None: """Invalidate cached language resolution and catalogs (call after ``save_config`` changes ``display.language``).""" _config_language_cached.cache_clear() @@ -138,9 +145,15 @@ def reset_language_cache() -> None: def get_language() -> str: - """Resolve the active language using env > config > default order.""" - env_lang = os.environ.get("HERMES_LANGUAGE") - return _normalize_lang(env_lang) if env_lang else _config_language_cached() or DEFAULT_LANGUAGE + """Resolve the active language using env > config > default order. ``HERMES_LANGUAGE`` is a + per-profile ``.env`` value, so it is read through the secret scope: under multiplexing a raw + environ read would impose the default profile's language on every other profile.""" + from agent.secret_scope import UnscopedSecretError, get_secret + try: + env_lang = get_secret("HERMES_LANGUAGE") + except UnscopedSecretError: + env_lang = os.environ.get("HERMES_LANGUAGE") # unscoped default-profile path: environ IS its own value + return _normalize_lang(env_lang) if env_lang else _config_language() or DEFAULT_LANGUAGE def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str: diff --git a/agent/inline_tool_executors.py b/agent/inline_tool_executors.py index e1d5d1ad67..98b9ef85ea 100644 --- a/agent/inline_tool_executors.py +++ b/agent/inline_tool_executors.py @@ -104,7 +104,8 @@ def _session_search(agent, args: dict, ctx: InlineToolContext) -> Any: ( ("query", "query", ""), ("role_filter", "role_filter"), ("limit", "limit", 3), ("session_id", "session_id"), ("around_message_id", "around_message_id"), - ("window", "window", 5), ("sort", "sort"), ("detail", "detail", "adaptive"), + ("window", "window", 5), ("sort", "sort"), ("profile", "profile"), + ("detail", "detail", "adaptive"), ), db=session_db, current_session_id=agent.session_id, ) diff --git a/agent/outbound_webhooks.py b/agent/outbound_webhooks.py index 2a637bae24..a7df914645 100644 --- a/agent/outbound_webhooks.py +++ b/agent/outbound_webhooks.py @@ -14,7 +14,6 @@ import hashlib import hmac import json import logging -import os import queue import re import threading @@ -201,10 +200,14 @@ def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]: warn(".timeout must be an int (got %r); using default %ds", timeout_raw, DEFAULT_TIMEOUT_SECONDS) timeout = DEFAULT_TIMEOUT_SECONDS name = raw.get("name") - # ``secret_env`` (env var name, preferred) wins over inline ``secret``. + # ``secret_env`` (env var name, preferred) wins over inline ``secret``. Read through the profile + # secret scope: the gateway registers each multiplexed profile's targets inside that profile's + # scope, and a raw environ read would sign a secondary's deliveries with the DEFAULT profile's + # secret (or leave them unsigned when the var lives only in the secondary's .env). secret_env = raw.get("secret_env") if isinstance(secret_env, str) and secret_env.strip(): - secret = os.environ.get(secret_env.strip(), "") or None + from agent.secret_scope import get_secret + secret = get_secret(secret_env.strip(), "") or None if secret is None: warn(".secret_env=%r is not set in the environment — deliveries will be UNSIGNED", secret_env.strip()) else: diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 71814d7c9e..85df54235b 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -597,18 +597,45 @@ class GatewayAgentCacheMixin: return self._spawn_release_thread( self._release_evicted_agent_soft, (agent,), f"agent-evict-{str(session_key)[:24]}", inline_fallback=True, + session_key=session_key, ) - def _spawn_release_thread(self, target, args: tuple, name: str, *, inline_fallback: bool) -> None: + def _spawn_release_thread(self, target, args: tuple, name: str, *, inline_fallback: bool, + session_key: Optional[str] = None) -> None: """Run a release on a daemon thread. ``inline_fallback`` runs it inline (best-effort) when no - thread can start (interpreter shutdown); otherwise a spawn failure propagates, as on main.""" + thread can start (interpreter shutdown); otherwise a spawn failure propagates, as on main. + The thread runs inside the owning profile's scope (see ``_run_release_in_profile_scope``).""" + import contextvars + ctx = contextvars.copy_context() try: - threading.Thread(target=target, args=args, daemon=True, name=name).start() + threading.Thread(target=ctx.run, args=(self._run_release_in_profile_scope, target, args, session_key), + daemon=True, name=name).start() except Exception: if not inline_fallback: raise with suppress(Exception): - target(*args) + ctx.run(self._run_release_in_profile_scope, target, args, session_key) + + def _run_release_in_profile_scope(self, target, args: tuple, session_key: Optional[str]) -> None: + """Call ``target(*args)`` under the profile that owns ``session_key``. Threads start with an + EMPTY context, so a bare thread would commit end-of-session memory (provider ``on_session_end`` + reads credentials/home at call time) under the LAUNCH profile — lost memories or a secondary's + transcript extracted into the default profile's provider namespace. In-turn callers already + carry the scope (``copy_context`` preserves it); the unscoped housekeeping sweep resolves the + owner from the session key (``agent::...``) and enters that profile's scope.""" + from agent.secret_scope import current_secret_scope, is_multiplex_active + if current_secret_scope() is not None or not is_multiplex_active(): + target(*args) + return + from gateway.run import _profile_runtime_scope + from hermes_constants import get_hermes_home + home = None + store = getattr(self, "session_store", None) + if session_key and store is not None: + with suppress(Exception): + home = store._profile_home_for_key(session_key) + with _profile_runtime_scope(home or get_hermes_home()): + target(*args) def _commit_memory_before_soft_evict(self, agent: Any, key: str) -> None: """Commit the live transcript to memory providers before resource-only eviction.""" @@ -763,7 +790,8 @@ class GatewayAgentCacheMixin: while plan: key, agent = plan.pop(0) # FIFO — evict LRU-first order preserved try: - self._commit_then_release_soft(agent, key) + # Pressure sweeps run from the unscoped housekeeping watcher: enter each owner's scope. + self._run_release_in_profile_scope(self._commit_then_release_soft, (agent, key), key) except Exception as _e: logger.debug("Pressure release failed for %s: %s", key, _e) del agent @@ -803,7 +831,8 @@ class GatewayAgentCacheMixin: if agent is not None: # Commit end-of-session memory, then soft-release, both on the daemon thread so the # (possibly network-bound) provider call never blocks the held cache lock. - self._spawn_release_thread(self._commit_then_release_soft, (agent, key), f"agent-cache-evict-{key[:24]}", inline_fallback=False) + self._spawn_release_thread(self._commit_then_release_soft, (agent, key), f"agent-cache-evict-{key[:24]}", + inline_fallback=False, session_key=key) def _sweep_idle_cached_agents(self) -> int: """Evict cached agents idle past the idle TTL (lock acquired internally; cleanup on daemon @@ -829,5 +858,6 @@ class GatewayAgentCacheMixin: _cache.pop(key, None) for key, agent in to_evict: logger.info("Agent cache idle-TTL evict: session=%s (idle=%.0fs)", key, now - getattr(agent, "_last_activity_ts", now)) - self._spawn_release_thread(self._commit_then_release_soft, (agent, key), f"agent-cache-idle-{key[:24]}", inline_fallback=False) + self._spawn_release_thread(self._commit_then_release_soft, (agent, key), f"agent-cache-idle-{key[:24]}", + inline_fallback=False, session_key=key) return len(to_evict) diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 472b008a7c..8b3b0a4dec 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -2352,9 +2352,16 @@ class GatewayTurnMixin: return t("gateway.reload_mcp.failed", error=e) def _get_proxy_url(self) -> Optional[str]: - """Proxy URL if proxy mode is configured (GATEWAY_PROXY_URL env wins over ``gateway.proxy_url``).""" + """Proxy URL if proxy mode is configured (GATEWAY_PROXY_URL env wins over ``gateway.proxy_url``). + Per-profile like GATEWAY_PROXY_KEY: under multiplex a raw environ read would ship a secondary's + turns (authenticated with ITS scoped key) to the default profile's proxy. Same fallback shape as + the key — only ``UnscopedSecretError`` (the unscoped default-profile path) reads the env.""" from gateway.run import _load_gateway_config - url = os.getenv("GATEWAY_PROXY_URL", "").strip() + from agent.secret_scope import UnscopedSecretError, get_secret + try: + url = (get_secret("GATEWAY_PROXY_URL") or "").strip() + except UnscopedSecretError: + url = os.getenv("GATEWAY_PROXY_URL", "").strip() if not url: url = ((_load_gateway_config().get("gateway") or {}).get("proxy_url") or "").strip() return url.rstrip("/") if url else None diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 97057274f3..59873e998f 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -139,10 +139,17 @@ def _nous_inference_env_override() -> Optional[str]: """User-set ``NOUS_INFERENCE_BASE_URL`` override (trailing slash stripped) or None. Documented dev/staging escape hatch; the env source is trusted, so unlike Portal-returned URLs - it is intentionally NOT gated by the network host allowlist. + it is intentionally NOT gated by the network host allowlist. Read through the profile-aware + resolver so a multiplexed profile uses its own override and never inherits the default + profile's process-wide value (#65941). """ from hermes_cli.auth import _optional_base_url - return _optional_base_url(os.getenv("NOUS_INFERENCE_BASE_URL")) + from agent.secret_scope import UnscopedSecretError, get_secret + try: + override = get_secret("NOUS_INFERENCE_BASE_URL") + except UnscopedSecretError: + override = os.getenv("NOUS_INFERENCE_BASE_URL") # unscoped default-profile/CLI path: environ IS its own value + return _optional_base_url(override) def _nous_portal_env_override() -> Optional[str]: diff --git a/plugins/browser/browserbase/provider.py b/plugins/browser/browserbase/provider.py index 9c1dc982db..2a9c51e28f 100644 --- a/plugins/browser/browserbase/provider.py +++ b/plugins/browser/browserbase/provider.py @@ -48,7 +48,8 @@ class BrowserbaseBrowserProvider(CloudBrowserProvider): return { "api_key": api_key, "project_id": project_id, - "base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"), + # Per-profile like the key: the scoped key must not be sent to the default profile's endpoint. + "base_url": (get_secret("BROWSERBASE_BASE_URL", "") or "https://api.browserbase.com").rstrip("/"), } def _headers(self, config: Dict[str, Any]) -> Dict[str, str]: diff --git a/plugins/browser/firecrawl/provider.py b/plugins/browser/firecrawl/provider.py index d8f1ee4398..e2b17d9256 100644 --- a/plugins/browser/firecrawl/provider.py +++ b/plugins/browser/firecrawl/provider.py @@ -31,7 +31,8 @@ class FirecrawlBrowserProvider(CloudBrowserProvider): ] def _api_url(self) -> str: - return os.environ.get("FIRECRAWL_API_URL", _BASE_URL) + # Per-profile like the key: the scoped key must not be sent to the default profile's endpoint. + return get_secret("FIRECRAWL_API_URL", "") or _BASE_URL def _get_config_or_none(self) -> Optional[Dict[str, Any]]: return {"base_url": self._api_url()} if get_secret("FIRECRAWL_API_KEY") else None diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index a035edd13b..381b43c657 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -241,17 +241,20 @@ def _load_config() -> dict: if path.exists(): with contextlib.suppress(Exception): return json.loads(path.read_text(encoding="utf-8")) + # Mode, bank (the data partition), endpoint and retain shaping are per-profile .env values like + # the key beside them: read through the secret scope so a multiplexed secondary never inherits + # the default profile's bank/mode. Tuning knobs (timeouts, budget) stay process-global. return { - "mode": os.environ.get("HINDSIGHT_MODE", "cloud"), + "mode": get_secret("HINDSIGHT_MODE", "") or "cloud", "apiKey": get_secret("HINDSIGHT_API_KEY", ""), "timeout": _parse_int_setting(os.environ.get("HINDSIGHT_TIMEOUT"), _DEFAULT_TIMEOUT), "idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT), - "retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""), - "observation_scopes": os.environ.get("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", ""), + "retain_tags": get_secret("HINDSIGHT_RETAIN_TAGS", "") or "", + "observation_scopes": get_secret("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", "") or "", "retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", _DEFAULT_RETAIN_SOURCE), "retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"), "retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"), - "banks": {"hermes": {"bankId": os.environ.get("HINDSIGHT_BANK_ID", "hermes"), + "banks": {"hermes": {"bankId": get_secret("HINDSIGHT_BANK_ID", "") or "hermes", "budget": os.environ.get("HINDSIGHT_BUDGET", "mid"), "enabled": True}}, } @@ -357,7 +360,7 @@ class HindsightMemoryProvider(MemoryProvider): if mode in _LOCAL_MODES: return _check_local_runtime()[0] return mode == "local_external" or bool( - _cloud_api_key(cfg) or cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", "")) + _cloud_api_key(cfg) or cfg.get("api_url") or get_secret("HINDSIGHT_API_URL", "")) except Exception: return False @@ -708,7 +711,7 @@ class HindsightMemoryProvider(MemoryProvider): """Endpoint, bank and mode selectors from *cfg* (env fallbacks where documented).""" self._api_key = _cloud_api_key(cfg) default_url = _DEFAULT_LOCAL_URL if self._mode in {"local_embedded", "local_external"} else _DEFAULT_API_URL - self._api_url = cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url) + self._api_url = cfg.get("api_url") or get_secret("HINDSIGHT_API_URL", "") or default_url self._llm_base_url = cfg.get("llm_base_url", "") banks = cfg_get(cfg, "banks", "hermes", default={}) diff --git a/plugins/memory/hindsight/embedded.py b/plugins/memory/hindsight/embedded.py index 1a4ffa433a..27bf2de2e9 100644 --- a/plugins/memory/hindsight/embedded.py +++ b/plugins/memory/hindsight/embedded.py @@ -170,7 +170,14 @@ def _build_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | No "HINDSIGHT_API_LLM_MODEL": str(config.get("llm_model", "")), "HINDSIGHT_API_LOG_LEVEL": "info", } - base_url = config.get("llm_base_url") or os.environ.get("HINDSIGHT_API_LLM_BASE_URL", "") + # Base URL is per-profile like the key beside it (the scoped key must not go to the default's host); + # on the scopeless daemon worker a miss is a miss, never os.environ (same rule as the key above). + base_url = config.get("llm_base_url") + if not base_url: + try: + base_url = get_secret("HINDSIGHT_API_LLM_BASE_URL", "") or "" + except UnscopedSecretError: + base_url = "" if base_url: env_values["HINDSIGHT_API_LLM_BASE_URL"] = str(base_url) if (idle_timeout := config.get("idle_timeout")) is None: diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index e600bb651a..035173688a 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -67,9 +67,11 @@ def _host_block(raw: dict, host: str) -> dict: def resolve_active_host() -> str: - """Honcho host key: HERMES_HONCHO_HOST env, else the active profile. The config's - ``defaultHost`` is honored only for the default profile so named profiles stay isolated.""" - explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip() + """Honcho host key: HERMES_HONCHO_HOST (profile-scoped .env), else the active profile. The config's + ``defaultHost`` is honored only for the default profile so named profiles stay isolated — which is + also why the override is read through the secret scope: from raw environ it would fold every + multiplexed profile onto the default profile's host block and peer.""" + explicit = (get_secret("HERMES_HONCHO_HOST", "") or "").strip() if explicit: return explicit try: @@ -249,8 +251,10 @@ def _is_local_base_url(base_url: str | None) -> bool: def _env_base_url() -> str | None: - """HONCHO_BASE_URL / HONCHO_URL (the SDK's own var); a deployment setting, so plain os.environ.""" - return os.environ.get("HONCHO_BASE_URL", "").strip() or os.environ.get("HONCHO_URL", "").strip() or None + """HONCHO_BASE_URL / HONCHO_URL (the SDK's own var). A self-hosted URL varies per profile, so it is + read through the secret scope: the scoped HONCHO_API_KEY beside it must not be sent to the default + profile's server.""" + return (get_secret("HONCHO_BASE_URL", "") or "").strip() or (get_secret("HONCHO_URL", "") or "").strip() or None def _connection_fields(look: _HostLookup, host: str, path: Path) -> dict[str, Any]: @@ -412,7 +416,7 @@ class HonchoClientConfig: base_url = _sanitize_url(_env_base_url()) return cls( host=resolved_host, workspace_id=workspace_id, api_key=api_key, base_url=base_url, - environment=os.environ.get("HONCHO_ENVIRONMENT", "production"), + environment=get_secret("HONCHO_ENVIRONMENT", "") or "production", timeout=_resolve_optional_float(os.environ.get("HONCHO_TIMEOUT")), ai_peer=resolved_host, enabled=bool(api_key or base_url), config_path=resolve_config_path(), hermes_home=get_hermes_home(), diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index acbaadd388..8d41f71f40 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import Any, Dict, List from agent.memory_provider import MemoryProvider -from agent.secret_scope import get_secret +from agent.secret_scope import UnscopedSecretError, get_secret from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -73,6 +73,15 @@ def _read_mem0_json(config_path: Path) -> dict: return {} +def _scoped_env(name: str) -> str: + """Profile-scoped read of a non-secret mem0 setting; no scope under multiplex = unset (never + ``os.environ``). Only the API key may fail closed — OSS mode has none to read (#99121).""" + try: + return get_secret(name, "") or "" + except UnscopedSecretError: + return "" + + def _load_config() -> dict: """Env vars provide defaults; $HERMES_HOME/mem0.json overrides individual keys. Layering avoids a silent failure when the JSON file exists but lacks fields @@ -80,9 +89,9 @@ def _load_config() -> dict: from hermes_constants import get_hermes_home # Identity (user/agent id), host and mode are .env values like the key: read them through the # profile scope too, or a secondary profile's memories land in the default profile's account. - config = {"mode": get_secret("MEM0_MODE", "") or "platform", "host": get_secret("MEM0_HOST", "") or "", - "agent_id": get_secret("MEM0_AGENT_ID", "") or "hermes", "oss": {}} - if user_id := get_secret("MEM0_USER_ID", ""): # only when explicitly configured, so initialize() can fall back to the gateway-native id + config = {"mode": _scoped_env("MEM0_MODE") or "platform", "host": _scoped_env("MEM0_HOST"), + "agent_id": _scoped_env("MEM0_AGENT_ID") or "hermes", "oss": {}} + if user_id := _scoped_env("MEM0_USER_ID"): # only when explicitly configured, so initialize() can fall back to the gateway-native id config["user_id"] = user_id file_cfg = _read_mem0_json(get_hermes_home() / "mem0.json") config.update({k: v for k, v in file_cfg.items() if v is not None and v != ""}) diff --git a/plugins/memory/mem0/_openai_llm.py b/plugins/memory/mem0/_openai_llm.py index a5e19ee446..bf58c1a4c5 100644 --- a/plugins/memory/mem0/_openai_llm.py +++ b/plugins/memory/mem0/_openai_llm.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os from typing import Dict, List, Optional, Union from mem0.configs.llms.base import BaseLlmConfig @@ -37,11 +36,15 @@ class DirectOpenAILLM(OpenAILLM): # Bypass OpenAILLM.__init__ (it picks OpenRouter when OPENROUTER_API_KEY is # set); LLMBase still owns validation and supported-parameter filtering. LLMBase.__init__(self, config) - api_key = self.config.api_key or os.getenv("OPENAI_API_KEY") + # OPENAI_API_KEY / OPENAI_BASE_URL are profile credentials: read them through the secret + # scope, never raw os.environ, or a multiplexed secondary's memory extraction runs on the + # default profile's OpenAI account (and its proxy). + from agent.secret_scope import get_secret + api_key = self.config.api_key or get_secret("OPENAI_API_KEY", "") if not api_key: raise ValueError("OpenAI API key is required for the Hermes Mem0 OSS provider") from openai import OpenAI - self.client = OpenAI(api_key=api_key, base_url=self.config.openai_base_url or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1") + self.client = OpenAI(api_key=api_key, base_url=self.config.openai_base_url or get_secret("OPENAI_BASE_URL", "") or "https://api.openai.com/v1") def generate_response(self, messages: List[Dict[str, str]], response_format=None, tools: Optional[List[Dict]] = None, tool_choice: str = "auto", **kwargs): params = self._get_supported_params(messages=messages, **kwargs) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 3bce35bf37..f5a9ee7f22 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -36,6 +36,7 @@ from urllib.request import url2pathname from agent.message_content import flatten_message_text from agent.memory_provider import MemoryProvider +from agent.secret_scope import get_secret from agent.skill_commands import extract_user_instruction_from_skill_message from hermes_cli import __version__ as _HERMES_VERSION from tools.registry import tool_error @@ -237,9 +238,11 @@ class _VikingClient: self._api_key = api_key # Account/user are local/trusted-mode tenant identity. API-key requests # omit these headers unless OpenViking explicitly asks for them (retry). - self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default") - self._user = user or os.environ.get("OPENVIKING_USER", "default") - self._agent = agent if agent is not None else os.environ.get("OPENVIKING_AGENT", _DEFAULT_AGENT) + # Tenant identity is a profile .env value: scope-read so a multiplexed + # secondary never writes into the default profile's tenant. + self._account = account or get_secret("OPENVIKING_ACCOUNT", "") or "default" + self._user = user or get_secret("OPENVIKING_USER", "") or "default" + self._agent = agent if agent is not None else (get_secret("OPENVIKING_AGENT", "") or _DEFAULT_AGENT) self._httpx = _get_httpx() if self._httpx is None: raise ImportError("httpx is required for OpenViking: pip install httpx") @@ -759,19 +762,21 @@ def _ovcli_values_for(provider_config: dict) -> dict: def _resolve_connection_settings(provider_config: Optional[dict] = None) -> dict: """Layering: env -> linked ovcli profile -> config.yaml -> built-in default. An env account/user (even empty) is authoritative; the secret api_key never - comes from config.yaml.""" + comes from config.yaml. Every env read goes through the profile secret scope: + under multiplexing ``os.environ`` is the DEFAULT profile's .env, and a raw read + would spend its key and tenant on behalf of a secondary profile.""" provider_config = dict(provider_config or {}) ovcli_values = _ovcli_values_for(provider_config) def layered(key: str, default: str = "", *, env_authoritative: bool = False) -> str: - env = os.environ.get(f"OPENVIKING_{key.upper()}") + env = get_secret(f"OPENVIKING_{key.upper()}") if env is not None: env = env.strip() if env_authoritative: return env return env or ovcli_values.get(key) or _clean_config_value(provider_config.get(key)) or default - api_key_env = os.environ.get("OPENVIKING_API_KEY") + api_key_env = get_secret("OPENVIKING_API_KEY") return { "endpoint": _normalize_openviking_url(layered("endpoint", _DEFAULT_ENDPOINT)), "api_key": api_key_env.strip() if api_key_env is not None else ovcli_values.get("api_key", ""), @@ -1254,7 +1259,7 @@ class OpenVikingMemoryProvider(MemoryProvider): def is_available(self) -> bool: """Configured? (env endpoint, config.yaml endpoint, or a linked ovcli profile). No network.""" - if os.environ.get("OPENVIKING_ENDPOINT"): + if get_secret("OPENVIKING_ENDPOINT", ""): return True provider_config = _load_hermes_openviking_config() if _clean_config_value(provider_config.get("endpoint")): diff --git a/plugins/memory/retaindb/__init__.py b/plugins/memory/retaindb/__init__.py index 95ed0ded36..1e421b3659 100644 --- a/plugins/memory/retaindb/__init__.py +++ b/plugins/memory/retaindb/__init__.py @@ -327,11 +327,13 @@ class RetainDBMemoryProvider(MemoryProvider): ] def initialize(self, session_id: str, **kwargs) -> None: - # Non-secret fields resolve env -> config.yaml (written by the Dashboard) -> default. + # Non-secret fields resolve env (profile-scoped) -> config.yaml (written by the Dashboard) -> default. cfg = {k: v.strip() for k, v in _load_retaindb_config().items() if isinstance(v, str)} - base_url = re.sub(r"/+$", "", os.environ.get("RETAINDB_BASE_URL") or cfg.get("base_url") or _DEFAULT_BASE_URL) + base_url = re.sub(r"/+$", "", get_secret("RETAINDB_BASE_URL", "") or cfg.get("base_url") or _DEFAULT_BASE_URL) # Project: RETAINDB_PROJECT > config.yaml > hermes- > "default" (API auto-creates "default"). - project = os.environ.get("RETAINDB_PROJECT") or cfg.get("project") + # The project is the data partition: read through the secret scope so a multiplexed secondary's + # memories never land in the default profile's project. + project = get_secret("RETAINDB_PROJECT", "") or cfg.get("project") if not project: profile_name = os.path.basename(str(kwargs.get("hermes_home", ""))) project = f"hermes-{profile_name}" if profile_name not in {"", ".hermes"} else "default" diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index ee6a1c8717..49e8362e92 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -55,8 +55,8 @@ def _sanitize_tag(raw: str) -> str: def _resolve_base_url(config_value: Any = "") -> str: - """config > SUPERMEMORY_BASE_URL env var > default (self-hosted support).""" - raw = str(config_value or "").strip() or os.environ.get("SUPERMEMORY_BASE_URL", "").strip() + """config > SUPERMEMORY_BASE_URL (profile-scoped) > default (self-hosted support).""" + raw = str(config_value or "").strip() or (get_secret("SUPERMEMORY_BASE_URL", "") or "").strip() return (raw or _DEFAULT_BASE_URL).rstrip("/") or _DEFAULT_BASE_URL @@ -247,8 +247,10 @@ def _build_client(api_key: str, config: dict, container_tag: str) -> _Supermemor def _resolve_container_tag(config_tag: str, identity: str) -> str: - """SUPERMEMORY_CONTAINER_TAG env > config > default; {identity} expands to the agent identity, then sanitize.""" - raw_tag = os.environ.get("SUPERMEMORY_CONTAINER_TAG", "").strip() or config_tag + """SUPERMEMORY_CONTAINER_TAG (profile-scoped) > config > default; {identity} expands to the agent + identity, then sanitize. The container is the data partition, so it must never be borrowed from + the default profile's environ under multiplexing.""" + raw_tag = (get_secret("SUPERMEMORY_CONTAINER_TAG", "") or "").strip() or config_tag return _sanitize_tag(raw_tag.replace("{identity}", identity)) diff --git a/plugins/video_gen/xai/__init__.py b/plugins/video_gen/xai/__init__.py index 8365116bbc..13d59da699 100644 --- a/plugins/video_gen/xai/__init__.py +++ b/plugins/video_gen/xai/__init__.py @@ -13,7 +13,6 @@ import asyncio import base64 import logging import mimetypes -import os import uuid from contextlib import closing from pathlib import Path @@ -66,10 +65,13 @@ def _xai_http(helper: str, fallback: Any, *args: Any, log: Optional[str] = None) def _resolve_xai_credentials() -> Tuple[str, str]: - """``(api_key, base_url)``: runtime xai-oauth pool entry → ``auth.json`` OAuth tokens → ``XAI_API_KEY`` (empty key = none; callers check).""" + """``(api_key, base_url)``: runtime xai-oauth pool entry → ``auth.json`` OAuth tokens → ``XAI_API_KEY`` + (empty key = none; callers check). ``resolve_xai_http_credentials`` already applies the profile + secret scope to both fields, so a miss stays a miss: a raw ``os.getenv`` fallback here would hand a + multiplexed secondary the default profile's key after the scoped resolver correctly returned none.""" creds = _xai_http("resolve_xai_http_credentials", {}, log="xAI credential resolver failed: %s") or {} - base_url = str(creds.get("base_url") or os.getenv("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL) - return str(creds.get("api_key") or os.getenv("XAI_API_KEY", "")).strip(), base_url.strip().rstrip("/") + base_url = str(creds.get("base_url") or DEFAULT_XAI_BASE_URL) + return str(creds.get("api_key") or "").strip(), base_url.strip().rstrip("/") def _xai_headers(api_key: str) -> Dict[str, str]: diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index 57ed20300f..d319e42c72 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -89,10 +89,34 @@ def test_default_when_nothing_set(monkeypatch): monkeypatch.delenv("HERMES_LANGUAGE", raising=False) # Force config lookup to return None -- patch the cached reader. i18n.reset_language_cache() - monkeypatch.setattr(i18n, "_config_language_cached", lambda: None) + monkeypatch.setattr(i18n, "_config_language", lambda: None) assert i18n.get_language() == "en" +def test_language_is_per_profile_under_multiplex(monkeypatch, tmp_path): + """HERMES_LANGUAGE in the DEFAULT profile's environ must not leak into a secondary profile's + turn, and the config-language cache must not freeze one profile's ``display.language`` for all.""" + from agent import secret_scope + + default_home = tmp_path / "default"; default_home.mkdir() + prof_b = tmp_path / "b"; prof_b.mkdir() + (default_home / "config.yaml").write_text("display:\n language: fr\n") + (prof_b / "config.yaml").write_text("display:\n language: de\n") + monkeypatch.setenv("HERMES_LANGUAGE", "zh") # default profile's .env, bridged into environ + i18n.reset_language_cache() + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({}) + try: + monkeypatch.setenv("HERMES_HOME", str(default_home)) + assert i18n.get_language() == "fr" # scoped miss: env ignored, this profile's config wins + monkeypatch.setenv("HERMES_HOME", str(prof_b)) + assert i18n.get_language() == "de" # not the first profile's cached "fr" + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + i18n.reset_language_cache() + + # --------------------------------------------------------------------------- # t() semantics # --------------------------------------------------------------------------- diff --git a/tests/agent/test_inline_session_search_profile.py b/tests/agent/test_inline_session_search_profile.py new file mode 100644 index 0000000000..62a31804a7 --- /dev/null +++ b/tests/agent/test_inline_session_search_profile.py @@ -0,0 +1,45 @@ +"""The inline ``session_search`` executor forwards ``profile`` so a routed gateway agent can read a +named profile's store instead of silently searching the injected (default) DB (#82903). + +The gateway hard-injects ``db=agent._get_session_db_for_recall()``; without ``profile`` reaching +``tools.session_search_tool.session_search`` the tool's ``_resolve_profile_db`` never runs and +``profile="llm-wiki"`` returns the same rows as no profile at all. +""" + +import json +from pathlib import Path +from types import SimpleNamespace + +from agent.inline_tool_executors import INLINE_TOOL_EXECUTORS, InlineToolContext +from hermes_state import SessionDB + + +def test_session_search_honours_requested_profile_db(tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + profile_home = hermes_home / "profiles" / "llm-wiki" + profile_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + current_db = SessionDB(hermes_home / "state.db") + current_db.create_session("default-session", source="gateway") + current_db.append_message("default-session", role="user", content="weekly report default") + current_db._conn.commit() + profile_db = SessionDB(profile_home / "state.db") + profile_db.create_session("profile-session", source="cli") + profile_db.append_message("profile-session", role="user", content="weekly report llm wiki") + profile_db._conn.commit() + profile_db.close() + + agent = SimpleNamespace(_get_session_db_for_recall=lambda: current_db, session_id="gw-1") + ctx = InlineToolContext(effective_task_id="task-1", tool_call_id="call-1") + try: + routed = json.loads(INLINE_TOOL_EXECUTORS["session_search"]( + agent, {"query": "weekly report", "profile": "llm-wiki"}, ctx)) + missing = json.loads(INLINE_TOOL_EXECUTORS["session_search"]( + agent, {"query": "weekly report", "profile": "missing-profile"}, ctx)) + finally: + current_db.close() + + assert [r["session_id"] for r in routed["results"]] == ["profile-session"] + assert missing["success"] is False and "default-session" not in json.dumps(missing) diff --git a/tests/agent/test_multiplex_base_url_scope.py b/tests/agent/test_multiplex_base_url_scope.py new file mode 100644 index 0000000000..100a2ca5d7 --- /dev/null +++ b/tests/agent/test_multiplex_base_url_scope.py @@ -0,0 +1,65 @@ +"""Multiplex invariant: a scoped API key is never paired with the DEFAULT profile's base URL / proxy. + +`agent/auxiliary_client.py` already scopes provider keys via ``_scoped_key_env``; the base URLs beside +them (OPENAI_BASE_URL, XAI_BASE_URL, NOUS_INFERENCE_BASE_URL) and the gateway proxy URL / browser +provider endpoints must follow the same rule, or a secondary's key is sent to another profile's host. +""" +from __future__ import annotations + +import pytest + +from agent import secret_scope + + +@pytest.fixture +def secondary_scope(monkeypatch): + for name in ("OPENAI_BASE_URL", "XAI_BASE_URL", "HERMES_XAI_BASE_URL", "NOUS_INFERENCE_BASE_URL", + "GATEWAY_PROXY_URL", "FIRECRAWL_API_URL", "BROWSERBASE_BASE_URL", "XAI_API_KEY"): + monkeypatch.setenv(name, f"https://{name.lower()}.default.example/v1") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"BROWSERBASE_API_KEY": "bb-b", "BROWSERBASE_PROJECT_ID": "pid-b"}) + try: + yield + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + +def test_base_urls_follow_the_scoped_key_not_default_environ(monkeypatch, secondary_scope, tmp_path): + import agent.auxiliary_client as aux + import plugins.browser.browserbase.provider as browserbase + import plugins.browser.firecrawl.provider as firecrawl + import plugins.video_gen.xai as xai_video + from gateway.run_turn import GatewayTurnMixin + from hermes_cli import auth_nous + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(aux, "_get_named_custom_provider", lambda name: None, raising=False) + + _, base = aux._expand_direct_api_alias("openai", None) + assert "default.example" not in (base or "") + assert aux._scoped_key_env("OPENAI_BASE_URL") == "" + assert auth_nous._nous_inference_env_override() is None + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}) + assert GatewayTurnMixin._get_proxy_url(GatewayTurnMixin.__new__(GatewayTurnMixin)) is None + assert "default.example" not in browserbase.BrowserbaseBrowserProvider()._get_config_or_none()["base_url"] + assert "default.example" not in firecrawl.FirecrawlBrowserProvider()._api_url() + # xAI video: a scoped miss stays a miss — no environ fallback-after-miss for the key or the URL. + api_key, base_url = xai_video._resolve_xai_credentials() + assert api_key == "" and "default.example" not in base_url + + +def test_unscoped_single_profile_reads_keep_environ(monkeypatch): + """Multiplex OFF (CLI / single gateway): environ IS the profile's own value — behaviour unchanged.""" + from gateway.run_turn import GatewayTurnMixin + from hermes_cli import auth_nous + + secret_scope.set_multiplex_active(False) + token = secret_scope.set_secret_scope(None) + try: + monkeypatch.setenv("GATEWAY_PROXY_URL", "https://proxy.mine.example/") + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", "https://nous.mine.example/v1/") + assert GatewayTurnMixin._get_proxy_url(GatewayTurnMixin.__new__(GatewayTurnMixin)) == "https://proxy.mine.example" + assert auth_nous._nous_inference_env_override() == "https://nous.mine.example/v1" + finally: + secret_scope.reset_secret_scope(token) diff --git a/tests/agent/test_outbound_webhooks.py b/tests/agent/test_outbound_webhooks.py index 39c8a25221..955c56468d 100644 --- a/tests/agent/test_outbound_webhooks.py +++ b/tests/agent/test_outbound_webhooks.py @@ -246,6 +246,24 @@ class TestParseConfig: ) assert targets[0].secret is None + def test_secret_env_resolves_from_profile_scope_under_multiplex(self, monkeypatch): + """Per-profile registration: a secondary's ``.env`` secret signs its deliveries; the DEFAULT + profile's environ value must never be borrowed when the secondary's scope lacks the var.""" + from agent import secret_scope + + monkeypatch.setenv("MY_HOOK_SECRET", "from-default-profile-env") + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"MY_HOOK_SECRET": "from-secondary-scope"}) + try: + raw = _cfg({"url": "https://example.com", "events": ["on_session_end"], "secret_env": "MY_HOOK_SECRET"}) + assert outbound_webhooks.iter_configured_targets(raw)[0].secret == "from-secondary-scope" + secret_scope.reset_secret_scope(token) + token = secret_scope.set_secret_scope({}) + assert outbound_webhooks.iter_configured_targets(raw)[0].secret is None + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + # ── matcher behaviour ───────────────────────────────────────────────────── diff --git a/tests/gateway/test_agent_cache_release_profile_scope.py b/tests/gateway/test_agent_cache_release_profile_scope.py new file mode 100644 index 0000000000..c02e830258 --- /dev/null +++ b/tests/gateway/test_agent_cache_release_profile_scope.py @@ -0,0 +1,67 @@ +"""Multiplex invariant: agent-cache eviction commits end-of-session memory under the OWNING profile. + +``_spawn_release_thread`` used to start a bare ``threading.Thread``; threads begin with an EMPTY +context, so provider ``on_session_end`` (credentials/home read at call time) ran under the launch +profile — a secondary's transcript was extracted into the default profile's memory namespace, or +failed closed (``UnscopedSecretError``) and the memories were lost. +""" +from __future__ import annotations + +import threading +from pathlib import Path +from types import SimpleNamespace + +from agent import secret_scope +from gateway.config import GatewayConfig +from gateway.run import GatewayRunner +from hermes_constants import get_hermes_home + + +def _runner(profile_homes: dict[str, Path]) -> GatewayRunner: + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.session_store = SimpleNamespace(_profile_home_for_key=lambda key: profile_homes.get(key)) + return runner + + +def _seen_after_release(runner, key, *, wait_scope=None) -> dict: + seen: dict = {} + done = threading.Event() + + def target(agent, k): + seen["scope"] = secret_scope.current_secret_scope() + seen["home"] = get_hermes_home() + done.set() + + runner._spawn_release_thread(target, (None, key), f"t-{key}", inline_fallback=False, session_key=key) + assert done.wait(5) + return seen + + +def test_unscoped_housekeeping_sweep_enters_the_owning_profile_scope(tmp_path, monkeypatch): + default_home = tmp_path / ".hermes" + prof_b = default_home / "profiles" / "b" + prof_b.mkdir(parents=True) + (prof_b / ".env").write_text("HINDSIGHT_LLM_API_KEY=key-of-b\n") + monkeypatch.setenv("HERMES_HOME", str(default_home)) + secret_scope.set_multiplex_active(True) + try: + # The housekeeping watcher runs with NO scope installed. + assert secret_scope.current_secret_scope() is None + seen = _seen_after_release(_runner({"agent:b:telegram:dm:1": prof_b}), "agent:b:telegram:dm:1") + finally: + secret_scope.set_multiplex_active(False) + assert seen["home"] == prof_b + assert seen["scope"] and seen["scope"].get("HINDSIGHT_LLM_API_KEY") == "key-of-b" + + +def test_in_turn_cap_eviction_keeps_the_callers_scope(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"MARKER": "turn-scope"}) + try: + seen = _seen_after_release(_runner({}), "agent:main:cli:1") + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + assert seen["scope"] == {"MARKER": "turn-scope"} diff --git a/tests/plugins/memory/test_multiplex_memory_identity_scope.py b/tests/plugins/memory/test_multiplex_memory_identity_scope.py new file mode 100644 index 0000000000..49f5e854f6 --- /dev/null +++ b/tests/plugins/memory/test_multiplex_memory_identity_scope.py @@ -0,0 +1,96 @@ +"""Multiplex invariant: memory-provider identity/tenant/endpoint never comes from the default profile. + +Under ``gateway.multiplex_profiles`` ``os.environ`` is the DEFAULT profile's ``.env``. When a secondary +profile's scope does not define MEM0_USER_ID / SUPERMEMORY_CONTAINER_TAG / RETAINDB_PROJECT / +OPENVIKING_* / HINDSIGHT_BANK_ID / HERMES_HONCHO_HOST, the provider must fall back to its own default +(per-profile partition), NOT write the secondary's memories into the default profile's account. +""" +from __future__ import annotations + +import pytest + +from agent import secret_scope + +_DEFAULT_ENV = { + "MEM0_USER_ID": "user-default", "MEM0_AGENT_ID": "agent-default", "MEM0_HOST": "http://mem0.default", + "MEM0_MODE": "self_hosted", + "SUPERMEMORY_CONTAINER_TAG": "tag-default", "SUPERMEMORY_BASE_URL": "https://sm.default", + "RETAINDB_PROJECT": "proj-default", "RETAINDB_BASE_URL": "https://rdb.default", + "OPENVIKING_API_KEY": "ov-default", "OPENVIKING_ACCOUNT": "acct-default", "OPENVIKING_USER": "user-default", + "OPENVIKING_AGENT": "agent-default", "OPENVIKING_ENDPOINT": "http://ov.default", + "HINDSIGHT_BANK_ID": "bank-default", "HINDSIGHT_MODE": "local_external", "HINDSIGHT_API_URL": "http://hs.default", + "HERMES_HONCHO_HOST": "host-default", "HONCHO_BASE_URL": "https://honcho.default", + "OPENAI_API_KEY": "sk-default", "OPENAI_BASE_URL": "https://openai.default/v1", +} + + +@pytest.fixture +def secondary_profile(monkeypatch, tmp_path): + """Multiplex ON; default profile's values in environ; secondary profile `b` scope with only its + own API keys (no identity/tenant/endpoint vars).""" + for k, v in _DEFAULT_ENV.items(): + monkeypatch.setenv(k, v) + home = tmp_path / ".hermes" + prof_b = home / "profiles" / "b" + prof_b.mkdir(parents=True) + (prof_b / "config.yaml").write_text("{}\n") + monkeypatch.setenv("HERMES_HOME", str(prof_b)) + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"RETAINDB_API_KEY": "rdb-b", "SUPERMEMORY_API_KEY": "sm-b", + "HONCHO_API_KEY": "honcho-b", "MEM0_API_KEY": "mem0-b"}) + try: + yield prof_b + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + +def test_secondary_profile_memory_identity_never_inherits_default_environ(secondary_profile): + import plugins.memory.hindsight as hindsight + import plugins.memory.mem0 as mem0 + import plugins.memory.openviking as openviking + import plugins.memory.retaindb as retaindb + import plugins.memory.supermemory as supermemory + from plugins.memory.honcho import client as honcho_client + + cfg = mem0._load_config() + assert "user_id" not in cfg # falls back to the gateway-native id, not the default's user + assert (cfg["agent_id"], cfg["host"], cfg["mode"]) == ("hermes", "", "platform") + + assert supermemory._resolve_container_tag("cfg_tag", "id") == "cfg_tag" + assert "default" not in supermemory._resolve_base_url("") + + provider = retaindb.RetainDBMemoryProvider() + provider.initialize("s1", hermes_home=str(secondary_profile)) + assert provider._client.project == "hermes-b" + assert "default" not in provider._client.base_url + + settings = openviking._resolve_connection_settings({}) + assert (settings["api_key"], settings["account"], settings["user"]) == ("", "", "") + assert "default" not in settings["endpoint"] + client = openviking._VikingClient("http://x", "k") + assert (client._account, client._user) == ("default", "default") # the built-in tenant, not acct-default + + hcfg = hindsight._load_config() + assert (hcfg["banks"]["hermes"]["bankId"], hcfg["mode"]) == ("hermes", "cloud") + + assert honcho_client.resolve_active_host() != "host-default" + assert honcho_client._env_base_url() is None + + +def test_mem0_oss_llm_never_borrows_default_profile_openai_key(secondary_profile): + pytest.importorskip("mem0") + from mem0.configs.llms.openai import OpenAIConfig + + from plugins.memory.mem0._openai_llm import DirectOpenAILLM + + with pytest.raises(ValueError, match="OpenAI API key is required"): + DirectOpenAILLM(OpenAIConfig(model="gpt-5-mini")) + # The secondary's own scoped key + base URL are used when present. + token = secret_scope.set_secret_scope({"OPENAI_API_KEY": "sk-b", "OPENAI_BASE_URL": "https://openai.b/v1"}) + try: + llm = DirectOpenAILLM(OpenAIConfig(model="gpt-5-mini")) + finally: + secret_scope.reset_secret_scope(token) + assert llm.client.api_key == "sk-b" + assert str(llm.client.base_url).startswith("https://openai.b/v1") diff --git a/tests/tools/test_multiplex_tool_credential_scope.py b/tests/tools/test_multiplex_tool_credential_scope.py new file mode 100644 index 0000000000..926da71b59 --- /dev/null +++ b/tests/tools/test_multiplex_tool_credential_scope.py @@ -0,0 +1,56 @@ +"""Multiplex invariant: tool-side profile credentials / targets never leak from the default profile. + +Under ``gateway.multiplex_profiles`` ``os.environ`` holds the DEFAULT profile's ``.env``; a secondary +profile's turn runs with a secret scope that may not define a var at all. Every reader below must +then see "unset", never the default profile's value (`agent/secret_scope.py::get_secret` contract). +""" +from __future__ import annotations + +import pytest + +from agent import secret_scope + + +@pytest.fixture +def secondary_scope(monkeypatch): + """Multiplex ON with a secondary profile's (empty) secret scope installed.""" + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({}) + try: + yield + finally: + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + +def test_scoped_tool_credential_gates_ignore_default_profile_environ(monkeypatch, secondary_scope, tmp_path): + from tools import browser_use_cli, read_extract, tool_backend_helpers + + monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-default") + monkeypatch.setenv("MODAL_TOKEN_ID", "id-default") + monkeypatch.setenv("MODAL_TOKEN_SECRET", "secret-default") + monkeypatch.setenv("BROWSER_USE_API_KEY", "bu-default") + monkeypatch.setattr(tool_backend_helpers.Path, "home", lambda: tmp_path) # no ~/.modal.toml + + enabled, api_key, _ = read_extract._hosted_ocr_config() + assert (enabled, api_key) == (False, None) + assert tool_backend_helpers.has_direct_modal_credentials() is False + assert browser_use_cli.is_legacy_browser_use_cloud_config({"cloud_provider": "browser-use"}) is False + + +def test_weixin_home_channel_resolves_from_profile_scope_not_environ(monkeypatch, secondary_scope): + from tools import send_message_tool + + class _NoHome: + def get_home_channel(self, platform): + return None + + monkeypatch.setenv("WEIXIN_HOME_CHANNEL", "wx-default-chat") + chat_id, err = send_message_tool._home_chat_id(_NoHome(), None, "weixin") + assert chat_id is None and err + # The secondary's own .env value is honoured. + token = secret_scope.set_secret_scope({"WEIXIN_HOME_CHANNEL": "wx-secondary-chat"}) + try: + assert send_message_tool._home_chat_id(_NoHome(), None, "weixin") == ("wx-secondary-chat", None) + finally: + secret_scope.reset_secret_scope(token) diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 8689efe52e..3b80646dae 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -198,7 +198,9 @@ def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool: provider = str(browser_cfg.get("cloud_provider") or "").strip().lower() if provider not in {"browser-use", ""} or _use_gateway(browser_cfg) or _camofox_active(" during migration"): return False - return bool(os.getenv("BROWSER_USE_API_KEY")) + # Profile credential: a multiplexed secondary must not inherit the default's cloud mode. + from agent.secret_scope import get_secret + return bool(get_secret("BROWSER_USE_API_KEY", "")) def is_browser_use_cli_mode() -> bool: diff --git a/tools/read_extract.py b/tools/read_extract.py index 0ae70c8ab9..b0e3725350 100644 --- a/tools/read_extract.py +++ b/tools/read_extract.py @@ -146,8 +146,10 @@ def _hosted_ocr_config() -> tuple: """(enabled, api_key, api_url); never raises, no network. Maintainer decision: the ONLY route is a direct ``FIRECRAWL_API_KEY`` (anydoc defaults api_url); the Nous gateway's Parse proxy live-probed broken, so it is NOT used. ``file_tools.hosted_ocr: false`` disables even with a - key.""" - api_key = os.environ.get("FIRECRAWL_API_KEY") or None + key. The key is a profile credential: read through the secret scope so a multiplexed + secondary never spends (or reveals its documents to) the default profile's Firecrawl key.""" + from agent.secret_scope import get_secret + api_key = get_secret("FIRECRAWL_API_KEY") or None enabled = api_key is not None with contextlib.suppress(Exception): from hermes_cli.config import load_config_readonly diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index cfaf016ae7..9b1e549fbc 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -305,7 +305,9 @@ def _home_chat_id(config, platform, platform_name): home = config.get_home_channel(platform) if home: return home.chat_id, None - wx_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip() if platform_name == "weixin" else "" + # Home channel is a per-profile target like the token beside it: a raw environ read would post a + # multiplexed secondary's message into the default profile's Weixin chat. + wx_home = (get_secret("WEIXIN_HOME_CHANNEL", "") or "").strip() if platform_name == "weixin" else "" if wx_home: return wx_home, None home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(platform_name, f"{platform_name.upper()}_HOME_CHANNEL") diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index 427a8b3584..52796a180b 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -61,8 +61,10 @@ normalize_modal_mode = coerce_modal_mode def has_direct_modal_credentials() -> bool: - """Return True when direct Modal credentials/config are available.""" - if os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET"): + """Return True when direct Modal credentials/config are available. The token pair is a + profile credential: read it through the secret scope so the default profile's Modal + account never selects the direct backend for a multiplexed secondary.""" + if _scoped_credential("MODAL_TOKEN_ID") and _scoped_credential("MODAL_TOKEN_SECRET"): return True try: return (Path.home() / ".modal.toml").exists() diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 3bba3c2825..fbc691088e 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -263,6 +263,21 @@ unauthorized-slash operator alert of `P`'s Discord bot (to `P`'s home channel). If `P` has no connected bot for that platform the send fails with a clear error — it never falls back to the default profile's bot. +Tool and memory-provider credentials follow the same rule. Hosted OCR +(`FIRECRAWL_API_KEY`), Modal / Browser Use cloud gates, the mem0 OSS OpenAI +key, xAI video, and every memory-provider identity (`MEM0_USER_ID`, +`SUPERMEMORY_CONTAINER_TAG`, `RETAINDB_PROJECT`, `OPENVIKING_ACCOUNT/USER`, +`HINDSIGHT_BANK_ID`, `HERMES_HONCHO_HOST`) are read from the routed profile's +`.env`, so a secondary profile's memories land in **its** account/bank/project +(or the provider's per-profile default), never the default profile's. Custom +endpoints travel with their keys — `OPENAI_BASE_URL`, `XAI_BASE_URL`, +`NOUS_INFERENCE_BASE_URL`, `GATEWAY_PROXY_URL`, Firecrawl / Browserbase / +RetainDB / Supermemory / Honcho / Hindsight URLs — so a profile's key is never +sent to another profile's proxy or self-hosted server. `WEIXIN_HOME_CHANNEL`, +`HERMES_LANGUAGE` and `display.language`, and `hooks.outbound[].secret_env` are +likewise per profile, and end-of-session memory extraction for an evicted +secondary session runs under that profile's scope. + ### Serving selected profiles By default, `gateway.multiplex_profiles: true` serves every valid named profile