fix(secret-scope): mem0, langfuse and azure credential readers stop swallowing UnscopedSecretError

Three shims caught UnscopedSecretError and degraded to "" (mem0._scoped_env)
or to os.environ (langfuse._secret, azure_identity_adapter._scoped_env). Under
multiplex os.environ holds the DEFAULT profile's .env, so the langfuse/azure
fallback could ship another profile's keys, and the mem0 fallback silently
routed a mis-spawned turn's memories into the default profile's account. The
exception exists to surface exactly that spawn-site bug (agent/AGENTS.md:
never add environ fallthrough, never swallow it). All three now call
agent.secret_scope.get_secret directly: with a scope installed a miss returns
the default; single-profile deployments (multiplex off) still read the process
env inside get_secret; a scope-less multiplex caller raises.

Behavior change: a mis-spawned child under gateway.multiplex_profiles now
fails loud with UnscopedSecretError instead of running silently unauthenticated
/ on the default profile's identity. The #99121 contract (OSS mode needs no
MEM0_API_KEY in scope) is unchanged and its test now installs an empty profile
scope, which is the situation the issue described; a genuinely scope-less
caller is asserted to raise in a new test.

Tests: tests/plugins/test_scoped_secret_readers_fail_closed.py (scope wins over
environ; scope-less multiplex raises) for langfuse + azure, sabotage red when
the langfuse fallthrough is restored; tests/plugins/memory/test_mem0_v3.py::
test_load_config_fails_closed_without_scope_even_for_identity_settings,
sabotage red with a swallowing wrapper reinstated.
This commit is contained in:
teknium1
2026-09-12 20:29:18 -07:00
committed by Teknium
parent 9ba5850e95
commit ca2ca5b9e1
5 changed files with 87 additions and 31 deletions
+21 -2
View File
@@ -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")
@@ -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)