From a1b689f12a2e30cc232e9946a5e3eab02dbf14b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=AF=BC=EC=9E=AC?= <19909783+honor2030@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:56:31 +0900 Subject: [PATCH] fix(mem0): skip platform key lookup in OSS mode Conflict resolution on top of main also routes MEM0_MODE / MEM0_HOST / MEM0_AGENT_ID / MEM0_USER_ID through get_secret: identity and host are .env values like the key, and a raw environ read hands a secondary profile the default profile's mem0 account. (cherry picked from commit 7a5863ebafdb193d8ec2f89a7aca309774d6d69a) --- plugins/memory/mem0/__init__.py | 18 +++++++-- tests/plugins/memory/test_mem0_v3.py | 56 +++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index cfe97916d6..acbaadd388 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -12,7 +12,6 @@ from __future__ import annotations import atexit import json import logging -import os import threading import time from contextlib import suppress @@ -79,11 +78,22 @@ def _load_config() -> dict: Layering avoids a silent failure when the JSON file exists but lacks fields like ``api_key`` that the user set in ``.env``.""" from hermes_constants import get_hermes_home - config = {"mode": os.environ.get("MEM0_MODE", "platform"), "api_key": get_secret("MEM0_API_KEY", ""), "host": os.environ.get("MEM0_HOST", ""), "agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"), "oss": {}} - if os.environ.get("MEM0_USER_ID"): # only when explicitly configured, so initialize() can fall back to the gateway-native id - config["user_id"] = os.environ["MEM0_USER_ID"] + # 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["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 != ""}) + # MEM0_API_KEY authenticates the Platform and self-hosted HTTP backends; pure OSS mode builds its + # backend from the local ``oss`` config and has no platform credential to resolve. Decide after + # mem0.json overrode the env fallback so a scope-less multiplex caller can load an OSS config + # without weakening fail-closed reads for credentialed modes. + if config.get("mode", "platform") == "oss": + config.setdefault("api_key", "") + elif not config.get("api_key"): + config["api_key"] = get_secret("MEM0_API_KEY", "") return config diff --git a/tests/plugins/memory/test_mem0_v3.py b/tests/plugins/memory/test_mem0_v3.py index 870d8d49dd..c1a1160143 100644 --- a/tests/plugins/memory/test_mem0_v3.py +++ b/tests/plugins/memory/test_mem0_v3.py @@ -5,6 +5,7 @@ import threading import time import pytest +from agent import secret_scope import plugins.memory.mem0 as mem0_plugin from plugins.memory.mem0 import Mem0MemoryProvider @@ -309,6 +310,59 @@ class TestMem0V3Config: class TestMem0ModeSwitch: + def test_oss_mode_initializes_without_unscoped_platform_key( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("MEM0_API_KEY", raising=False) + (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: + provider = Mem0MemoryProvider() + provider._create_backend = lambda: None # type: ignore[method-assign] + provider.initialize("test") + available = provider.is_available() + finally: + secret_scope.set_multiplex_active(False) + secret_scope.reset_secret_scope(token) + + assert provider._mode == "oss" + assert provider._api_key == "" + assert available is True + + def test_platform_config_still_fails_closed_without_profile_scope( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("MEM0_API_KEY", raising=False) + + token = secret_scope.set_secret_scope(None) + secret_scope.set_multiplex_active(True) + try: + with pytest.raises(secret_scope.UnscopedSecretError): + Mem0MemoryProvider().is_available() + 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") + (tmp_path / "mem0.json").write_text( + json.dumps({"api_key": "file-key"}) + ) + + assert mem0_plugin._load_config()["api_key"] == "file-key" + def test_default_mode_is_platform(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("MEM0_API_KEY", "test-key") @@ -453,5 +507,3 @@ class TestSelfHostedConfig: def test_load_config_reads_mem0_host_env(self, monkeypatch): monkeypatch.setenv("MEM0_HOST", "http://localhost:8888") assert mem0_plugin._load_config()["host"] == "http://localhost:8888" - -