diff --git a/agent/azure_identity_adapter.py b/agent/azure_identity_adapter.py index 76d4ded11d..7df2b13575 100644 --- a/agent/azure_identity_adapter.py +++ b/agent/azure_identity_adapter.py @@ -207,12 +207,11 @@ def _env(name: str) -> str: def _scoped_env(name: str) -> str: """Credential-bearing env read via the profile secret scope so a multiplexed profile never reports - another profile's env-bridged credentials; unscoped CLI probes fall back to plain env.""" - try: - from agent.secret_scope import get_secret - return (get_secret(name) or "").strip() - except Exception: # UnscopedSecretError, import failure, or any scope error - return _env(name) + another profile's env-bridged credentials. Unscoped CLI probes (multiplex off) read the process + env through ``get_secret`` itself; a scope-less multiplex caller raises — spawn-site bug.""" + from agent.secret_scope import get_secret + + return (get_secret(name) or "").strip() # (label, predicate) for env-var-driven credential sources, in chain order. diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index f5ec6770f5..ba305f9a14 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, spawn_context_thread -from agent.secret_scope import UnscopedSecretError, get_secret +from agent.secret_scope import get_secret from tools.registry import tool_error from utils import atomic_json_write, read_json_or_empty @@ -73,15 +73,6 @@ def _is_client_error(exc: Exception) -> bool: return type(exc).__name__ in _CLIENT_ERROR_TYPES or any(s in err_str for s in ("404", "not found", "valid uuid")) -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 @@ -89,9 +80,11 @@ 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": _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 + # A scope-less multiplex caller raises here on purpose — that is a spawn-site bug, and + # swallowing it would silently route the turn's memories to the default profile. + 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["user_id"] = user_id file_cfg = read_json_or_empty(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/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index cd1c22e294..6739916b96 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -88,16 +88,12 @@ def _env(name: str, default: str = "") -> str: def _secret(name: str) -> str: - """Credential read honoring the active profile's secret scope; plain os.environ when unscoped.""" - try: - from agent.secret_scope import UnscopedSecretError, get_secret - try: - return (get_secret(name) or "").strip() - except UnscopedSecretError: - pass - except Exception: - pass - return _env(name) + """Credential read through the profile secret scope. A scope-less multiplex caller raises + (``UnscopedSecretError``): that is a spawn-site bug, and reading ``os.environ`` instead would + ship this profile's traces with the DEFAULT profile's keys.""" + from agent.secret_scope import get_secret + + return (get_secret(name) or "").strip() def _debug(message: str) -> None: diff --git a/tests/plugins/memory/test_mem0_v3.py b/tests/plugins/memory/test_mem0_v3.py index d3e105ee96..028f55b52d 100644 --- a/tests/plugins/memory/test_mem0_v3.py +++ b/tests/plugins/memory/test_mem0_v3.py @@ -335,7 +335,7 @@ class TestMem0V3Config: class TestMem0ModeSwitch: - def test_oss_mode_initializes_without_unscoped_platform_key( + def test_oss_mode_initializes_without_platform_key_in_scope( self, monkeypatch, tmp_path ): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -349,7 +349,9 @@ class TestMem0ModeSwitch: ) ) - token = secret_scope.set_secret_scope(None) + # A profile scope WITHOUT the platform key: OSS mode must not demand MEM0_API_KEY. (A + # scope-less caller is a spawn-site bug and raises; see test_load_config_fails_closed_without_scope.) + token = secret_scope.set_secret_scope({}) secret_scope.set_multiplex_active(True) try: provider = Mem0MemoryProvider() @@ -379,6 +381,23 @@ class TestMem0ModeSwitch: secret_scope.set_multiplex_active(False) secret_scope.reset_secret_scope(token) + def test_load_config_fails_closed_without_scope_even_for_identity_settings( + self, monkeypatch, tmp_path + ): + """A scope-less multiplex caller is a spawn-site bug: identity/mode reads must surface it, + not degrade to '' and route the turn's memories into the default profile's account.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "mem0.json").write_text(json.dumps({"mode": "oss", "oss": {"vector_store": {"provider": "qdrant"}}})) + + token = secret_scope.set_secret_scope(None) + secret_scope.set_multiplex_active(True) + try: + with pytest.raises(secret_scope.UnscopedSecretError): + mem0_plugin._load_config() + finally: + secret_scope.set_multiplex_active(False) + secret_scope.reset_secret_scope(token) + def test_file_api_key_still_overrides_environment(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("MEM0_API_KEY", "env-key") diff --git a/tests/plugins/test_scoped_secret_readers_fail_closed.py b/tests/plugins/test_scoped_secret_readers_fail_closed.py new file mode 100644 index 0000000000..f4ba207305 --- /dev/null +++ b/tests/plugins/test_scoped_secret_readers_fail_closed.py @@ -0,0 +1,49 @@ +"""Credential shims outside the memory plugins honour the secret-scope contract. + +``langfuse._secret`` and ``azure_identity_adapter._scoped_env`` used to catch ``UnscopedSecretError`` +and fall back to ``os.environ`` / ``""``. Under multiplex ``os.environ`` is the DEFAULT profile's +``.env``, so that fallback either shipped another profile's credentials or hid the spawn-site bug the +exception exists to surface. Contract: scope wins over environ; no scope while multiplexing raises. +""" +from __future__ import annotations + +import pytest + +from agent import secret_scope +from agent.azure_identity_adapter import _scoped_env as azure_scoped_env +from plugins.observability.langfuse import _secret as langfuse_secret + +_READERS = {"langfuse": (langfuse_secret, "LANGFUSE_SECRET_KEY"), + "azure": (azure_scoped_env, "AZURE_CLIENT_SECRET")} + + +@pytest.fixture +def multiplex(monkeypatch): + secret_scope.set_multiplex_active(True) + try: + yield + finally: + secret_scope.set_multiplex_active(False) + + +@pytest.mark.parametrize("name", sorted(_READERS)) +def test_scoped_read_prefers_profile_scope_over_default_environ(name, monkeypatch, multiplex): + reader, var = _READERS[name] + monkeypatch.setenv(var, "default-profile-value") + token = secret_scope.set_secret_scope({var: " profile-b-value "}) + try: + assert reader(var) == "profile-b-value" + finally: + secret_scope.reset_secret_scope(token) + + +@pytest.mark.parametrize("name", sorted(_READERS)) +def test_scopeless_multiplex_read_fails_loud(name, monkeypatch, multiplex): + reader, var = _READERS[name] + monkeypatch.setenv(var, "default-profile-value") + token = secret_scope.set_secret_scope(None) + try: + with pytest.raises(secret_scope.UnscopedSecretError): + reader(var) + finally: + secret_scope.reset_secret_scope(token)