fix(multiplex): tool and memory-provider env reads stay inside the routed profile

Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's .env; a
secondary profile's values exist only in the per-turn secret scope. Every reader
below still read os.environ/os.getenv at call time, so a secondary profile's turn
silently used the default profile's value.

Credentials (F6): FIRECRAWL_API_KEY (read_file hosted OCR), OPENVIKING_API_KEY,
mem0-OSS OPENAI_API_KEY, MODAL_TOKEN_ID/SECRET and BROWSER_USE_API_KEY presence
gates, and the xAI video plugin's os.getenv("XAI_API_KEY") fallback AFTER the
scoped resolver had already missed — the exact fallback-after-miss shape
gateway/AGENTS.md forbids. Deleted, not re-scoped: the resolver is the scope.

Identity / tenant (F7): MEM0_USER_ID/AGENT_ID/HOST/MODE, SUPERMEMORY_CONTAINER_TAG,
RETAINDB_PROJECT, OPENVIKING_ACCOUNT/USER/AGENT (and the whole layered() env
read), HINDSIGHT_BANK_ID/MODE/retain shaping, HERMES_HONCHO_HOST. A raw read
put a secondary profile's memories into the default profile's account/bank/
project/tenant and recalled them back into the default's turns. Each now uses
get_secret with the provider's own per-profile default on a miss.

Endpoints (F8): OPENAI_BASE_URL (aux custom runtime + direct-alias expansion),
XAI_BASE_URL/HERMES_XAI_BASE_URL (aux OAuth), NOUS_INFERENCE_BASE_URL (#65941,
both the aux builder and hermes_cli.auth_nous._nous_inference_env_override),
GATEWAY_PROXY_URL (same UnscopedSecretError-only fallback shape as
GATEWAY_PROXY_KEY three lines below), FIRECRAWL_API_URL, BROWSERBASE_BASE_URL,
SUPERMEMORY/RETAINDB/HONCHO/HINDSIGHT URLs. The keys beside them were already
scoped, so a secondary's key was sent to the default profile's proxy or host.

Targets / display (F11): WEIXIN_HOME_CHANNEL (message posted into the default's
chat), HERMES_LANGUAGE, and agent/i18n's process-wide lru_cache of
display.language — now keyed by HERMES_HOME.

Outbound webhooks: hooks.outbound[].secret_env resolved from os.environ while
the gateway registers each profile's targets inside that profile's scope, so a
secondary's deliveries were signed with the default's secret or left unsigned.

Agent-cache eviction: _spawn_release_thread started a bare threading.Thread, so
commit_memory_session -> provider on_session_end ran with an EMPTY context. The
thread now runs copy_context() and, for the unscoped housekeeping sweep, enters
the owning profile's _profile_runtime_scope resolved from the session key
(agent:<profile>:...). The pressure batch does the same per key.

session_search (#82903): agent/inline_tool_executors.py::_session_search
forwarded every schema argument except `profile`, so a gateway agent could
never select a named profile's store. Forwarded; the ownership-scoping design
in #87779/#87847 is a separate design call and is not attempted here.

Live repro (/tmp/mux_audit/fix-tool-memory-reads/repro.py): 28 FAIL on
origin/main -> 0 FAIL with this change; 10 new invariant tests red on base.

Fixes #82903
Fixes #65941
Fixes #99121
Addresses #87779
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Michael Versluis (Berry) <michael@wve.nl>
This commit is contained in:
Teknium
2026-09-11 07:45:54 -07:00
parent a1b689f12a
commit a9838c2100
30 changed files with 569 additions and 74 deletions
+7 -6
View File
@@ -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:
+19 -6
View File
@@ -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:
+2 -1
View File
@@ -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,
)
+6 -3
View File
@@ -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:
+37 -7
View File
@@ -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:<profile>:...``) 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)
+9 -2
View File
@@ -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
+9 -2
View File
@@ -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]:
+2 -1
View File
@@ -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]:
+2 -1
View File
@@ -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
+9 -6
View File
@@ -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={})
+8 -1
View File
@@ -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:
+10 -6
View File
@@ -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(),
+13 -4
View File
@@ -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 != ""})
+6 -3
View File
@@ -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)
+12 -7
View File
@@ -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")):
+5 -3
View File
@@ -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-<profile> > "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"
+6 -4
View File
@@ -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))
+6 -4
View File
@@ -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]:
+25 -1
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -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)
@@ -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)
+18
View File
@@ -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 ─────────────────────────────────────────────────────
@@ -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"}
@@ -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")
@@ -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)
+3 -1
View File
@@ -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:
+4 -2
View File
@@ -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
+3 -1
View File
@@ -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")
+4 -2
View File
@@ -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()
@@ -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