fix(plugins): read the mem0 CLI key and ignore unexpanded host placeholders

The Python plugin core and the opencode, pi and deepseek plugins now fall back to the key `mem0 init` saves in ~/.mem0/config.json, so a configured CLI is enough to authenticate. The Python core also stops using or caching a literal ${api_key} left behind when a host (Cursor) does not expand its plugin variables, which is what kept asking for auth after the key was set.

Fixes #7346
This commit is contained in:
kartik-mem0
2026-09-25 10:24:04 +05:30
parent 8d6c001966
commit 71dc0cae07
32 changed files with 452 additions and 224 deletions
@@ -378,30 +378,46 @@ def resolve_repo(cwd: str | None) -> RepoContext:
return _resolve_repo_cached(os.path.abspath(cwd or os.getcwd()))
_PLUGIN_API_KEY_ENV = (
"PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_API_KEY",
"CLAUDE_PLUGIN_OPTION_MEM0_API_KEY",
)
def _configured(value: object) -> str:
"""The stripped value, or empty when the host left its ${placeholder} unexpanded."""
text = value.strip() if isinstance(value, str) else ""
return "" if text.startswith("${") and text.endswith("}") else text
def _first_env(*names: str) -> str:
return next((value for name in names if (value := _configured(os.environ.get(name)))), "")
def _mem0_cli_api_key() -> str:
"""The key `mem0 init` saved to the Mem0 CLI config."""
try:
config = json.loads((Path.home() / ".mem0" / "config.json").read_text(encoding="utf-8"))
return _configured(config["platform"]["api_key"])
except (OSError, ValueError, LookupError, TypeError):
return ""
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV)
if configured:
return configured
try:
return (data_dir() / "api-key").read_text(encoding="utf-8").strip()
cached = _configured((data_dir() / "api-key").read_text(encoding="utf-8"))
except OSError:
return ""
cached = ""
return cached or _mem0_cli_api_key()
def cache_plugin_api_key() -> bool:
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
configured = _first_env(*_PLUGIN_API_KEY_ENV)
if not configured:
return False
@@ -429,14 +445,7 @@ def cache_plugin_api_key() -> bool:
def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
if configured:
if _first_env("MEM0_API_KEY", *_PLUGIN_API_KEY_ENV):
return False
path = data_dir() / "api-key"
if not path.exists():
@@ -459,12 +468,7 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
return _first_env(f"PLUGIN_OPTION_{name.upper()}", f"CLAUDE_PLUGIN_OPTION_{name.upper()}", fallback)
def user_id() -> str:
@@ -19,5 +19,7 @@ API key is configured, the event/flush/retrieval counts (`flushes` is the
number of completed flushes, not a pending count), and the doctor check
results. If doctor reports an authentication failure (401 / invalid key), say
clearly that the Mem0 API key is invalid or expired and that memories are NOT
being created. Never report an auth failure as "no memories found". Suggest
reinstalling with `--config api_key=...` in that case.
being created. Never report an auth failure as "no memories found". When the
key is missing or invalid, suggest updating the plugin's API key setting,
exporting `MEM0_API_KEY`, or running `mem0 init` (the plugin reads the key the
Mem0 CLI saves in `~/.mem0/config.json`).
@@ -31,9 +31,13 @@ def isolated_env(tmp_path, monkeypatch):
monkeypatch.setenv("MEM0_CODE_DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("MEM0_CODE_USER_ID", "test-user")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("PLUGIN_OPTION_USER_ID", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
# The 0.2.x plugin exports these into every hooked shell; without this the
# suite fails for anyone running it inside a session with that plugin active.
monkeypatch.delenv("MEM0_PROJECT_ID", raising=False)
@@ -3750,6 +3754,71 @@ def test_stale_cached_api_key_is_cleared_when_config_is_removed(
assert memory_core.clear_stale_api_key_cache() is False
def _mem0_cli_init(home: Path, config: object) -> None:
(home / ".mem0").mkdir(parents=True, exist_ok=True)
(home / ".mem0" / "config.json").write_text(json.dumps(config), encoding="utf-8")
def test_api_key_falls_back_to_the_mem0_cli_config(isolated_env):
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": " m0-cli-key\n"}})
assert memory_core.api_key() == "m0-cli-key"
@pytest.mark.parametrize(
"config",
[{"platform": {}}, {"platform": "m0-oops"}, {"platform": {"api_key": 42}}, ["m0-list"]],
)
def test_malformed_mem0_cli_config_reads_as_no_key(isolated_env, config):
_mem0_cli_init(isolated_env / "home", config)
assert memory_core.api_key() == ""
def test_unreadable_mem0_cli_config_reads_as_no_key(isolated_env):
(isolated_env / "home" / ".mem0").mkdir(parents=True)
(isolated_env / "home" / ".mem0" / "config.json").write_text("{not json", encoding="utf-8")
assert memory_core.api_key() == ""
def test_plugin_configured_key_wins_over_the_mem0_cli_config(isolated_env, monkeypatch):
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "m0-plugin-key")
assert memory_core.cache_plugin_api_key() is True
assert memory_core.api_key() == "m0-plugin-key"
monkeypatch.delenv("PLUGIN_OPTION_API_KEY")
assert memory_core.api_key() == "m0-plugin-key"
def test_unexpanded_host_placeholder_is_never_used_as_the_api_key(isolated_env, monkeypatch):
monkeypatch.setenv("PLUGIN_OPTION_API_KEY", "${api_key}")
assert memory_core.cache_plugin_api_key() is False
assert not (isolated_env / "data" / "api-key").exists()
assert memory_core.api_key() == ""
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
assert memory_core.api_key() == "m0-cli-key"
def test_placeholder_cached_by_an_older_plugin_is_ignored(isolated_env):
(isolated_env / "data").mkdir()
(isolated_env / "data" / "api-key").write_text("${api_key}", encoding="utf-8")
_mem0_cli_init(isolated_env / "home", {"platform": {"api_key": "m0-cli-key"}})
assert memory_core.api_key() == "m0-cli-key"
def test_unexpanded_placeholder_plugin_options_fall_back(isolated_env, monkeypatch):
monkeypatch.setenv("PLUGIN_OPTION_USER_ID", "${user_id}")
monkeypatch.setenv("PLUGIN_OPTION_TOP_K", "${top_k}")
assert memory_core.user_id() == "test-user"
assert memory_core._plugin_option("top_k") == ""
def _big_batch_messages() -> list[dict[str, str]]:
return [
{"role": "user", "content": "A" * 20000},
@@ -24,6 +24,8 @@ def isolated_env(tmp_path, monkeypatch):
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.delenv("MEM0_API_URL", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
return tmp_path