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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user