fix(profiles): --clone copies the active memory provider's config (#120115)
--clone carried memory.provider (e.g. hindsight) in config.yaml but not the provider's own config under the profile home (hindsight/config.json, mem0.json, ...), so the clone booted with the provider selected and silently unavailable. Copy the ACTIVE provider's <provider>/ dir and/or <provider>.json by the same convention the dashboard memory-provider routers read, guard the name against path traversal, tighten copies to 0600 (they can hold an API key), and say so in the CLI notice. Convention-based on purpose: hindsight is a catalog plugin now, so a hook the plugin must implement could not fix the reported case, and no provider module is imported during profile create. Supersedes #43107 (credit @bionicbutterfly13 for the direction).
This commit is contained in:
@@ -215,6 +215,10 @@ def _profile_create(args):
|
||||
print(f"Full copy from {source_label} (excluding session history, cron jobs, backups, and snapshots).")
|
||||
else:
|
||||
print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.")
|
||||
from hermes_cli.profile_memory_config import cloned_memory_provider
|
||||
memory_provider = cloned_memory_provider(profile_dir)
|
||||
if memory_provider:
|
||||
print(f"Cloned memory provider config ({memory_provider}) too.")
|
||||
if sync_imports:
|
||||
print(f"Import sources carried over — `hermes -p {name} import-agent --sync` "
|
||||
"keeps pulling the same Claude Code / Codex trees.")
|
||||
|
||||
71
hermes_cli/profile_memory_config.py
Normal file
71
hermes_cli/profile_memory_config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Carry the ACTIVE memory provider's own config into a ``--clone`` (#120115).
|
||||
|
||||
``--clone`` copies ``config.yaml`` — and with it ``memory.provider: hindsight`` — but the
|
||||
provider keeps its settings outside config.yaml, so the clone booted with the provider
|
||||
selected and silently unavailable. Providers store per-home config by convention (the same
|
||||
convention ``hermes_cli.web_routers.memory_providers`` reads): a ``<home>/<provider>/``
|
||||
directory (hindsight) or a flat ``<home>/<provider>.json`` (mem0, honcho, supermemory). Copying
|
||||
by convention keeps this free of plugin imports: the provider may live in the catalog, not in
|
||||
tree, so a hook the plugin must implement could not fix the reported case.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# A provider name is a bare directory/file stem; anything else (path separators, ``..``, spaces)
|
||||
# would let a hand-edited config.yaml aim the copy outside the source profile.
|
||||
_PROVIDER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
def active_memory_provider(config: Optional[dict]) -> Optional[str]:
|
||||
"""The external ``memory.provider`` named in a parsed config.yaml, or None for the built-in
|
||||
store or an unsafe name."""
|
||||
from agent.memory_provider import is_core_memory_provider
|
||||
|
||||
memory = (config or {}).get("memory")
|
||||
name = memory.get("provider") if isinstance(memory, dict) else None
|
||||
if not isinstance(name, str) or is_core_memory_provider(name):
|
||||
return None
|
||||
name = name.strip()
|
||||
if name in {".", ".."} or not _PROVIDER_NAME_RE.match(name):
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def clone_memory_provider_config(source_dir: Path, profile_dir: Path, provider: Optional[str]) -> bool:
|
||||
"""Copy ``<provider>/`` and/or ``<provider>.json`` from *source_dir* into *profile_dir* when
|
||||
present. Files land owner-only like ``.env``: they can hold an API key. Returns True when
|
||||
anything was copied."""
|
||||
if not provider:
|
||||
return False
|
||||
copied = False
|
||||
src_dir = source_dir / provider
|
||||
if src_dir.is_dir():
|
||||
shutil.copytree(src_dir, profile_dir / provider, dirs_exist_ok=True)
|
||||
for root, _dirs, files in os.walk(profile_dir / provider):
|
||||
for filename in files:
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(os.path.join(root, filename), 0o600)
|
||||
copied = True
|
||||
src_file = source_dir / f"{provider}.json"
|
||||
if src_file.is_file():
|
||||
dst = profile_dir / f"{provider}.json"
|
||||
shutil.copy2(src_file, dst)
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(str(dst), 0o600)
|
||||
copied = True
|
||||
return copied
|
||||
|
||||
|
||||
def cloned_memory_provider(profile_dir: Path) -> Optional[str]:
|
||||
"""Name of the external provider whose config *profile_dir* now carries, for the CLI notice."""
|
||||
from hermes_cli.profiles import _load_yaml_dict
|
||||
|
||||
provider = active_memory_provider(_load_yaml_dict(profile_dir / "config.yaml"))
|
||||
if provider and ((profile_dir / provider).is_dir() or (profile_dir / f"{provider}.json").is_file()):
|
||||
return provider
|
||||
return None
|
||||
@@ -1196,6 +1196,9 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path],
|
||||
_copytree_keep_junctions(source_skills, profile_dir / "skills", _non_exportable_entries, dirs_exist_ok=True)
|
||||
for relpath in _CLONE_SUBDIR_FILES:
|
||||
_clone_file(source_dir, profile_dir, relpath)
|
||||
from hermes_cli.profile_memory_config import active_memory_provider, clone_memory_provider_config
|
||||
clone_memory_provider_config(source_dir, profile_dir,
|
||||
active_memory_provider(_load_yaml_dict(source_dir / "config.yaml")))
|
||||
if sync_imports:
|
||||
from hermes_cli.agent_import_sync import SYNC_MANIFEST_NAME # lazy: keeps yaml/utils off the hot startup path
|
||||
_clone_file(source_dir, profile_dir, SYNC_MANIFEST_NAME)
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import types
|
||||
@@ -217,6 +218,44 @@ class TestCreateProfile:
|
||||
assert (profile_dir / ".env").read_text().strip() == "KEY=val"
|
||||
assert (profile_dir / "SOUL.md").read_text() == "Be helpful."
|
||||
|
||||
def test_clone_config_copies_only_the_active_memory_providers_config(self, profile_env):
|
||||
"""#120115: --clone carried ``memory.provider: hindsight`` but not hindsight's own config,
|
||||
so the clone booted with memory silently unavailable. Only the ACTIVE provider's
|
||||
``<provider>/`` dir / ``<provider>.json`` travels; another provider's leftovers stay behind."""
|
||||
tmp_path = profile_env
|
||||
default_home = tmp_path / ".hermes"
|
||||
(default_home / "config.yaml").write_text("memory:\n provider: hindsight\n")
|
||||
(default_home / "hindsight").mkdir()
|
||||
payload = '{"mode": "local_embedded", "bank_id": "hermes", "apiKey": "hs-secret"}'
|
||||
(default_home / "hindsight" / "config.json").write_text(payload)
|
||||
(default_home / "mem0.json").write_text('{"agent_id": "hermes"}')
|
||||
|
||||
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
|
||||
|
||||
cloned = profile_dir / "hindsight" / "config.json"
|
||||
assert cloned.read_text() == payload
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(cloned.stat().st_mode) == 0o600
|
||||
assert not (profile_dir / "mem0.json").exists()
|
||||
|
||||
@pytest.mark.parametrize("provider", ["../outside", "a/b", "..", "hind sight"])
|
||||
def test_clone_config_ignores_unsafe_memory_provider_names(self, profile_env, provider):
|
||||
"""A hand-edited ``memory.provider`` must never aim the copy outside the source profile."""
|
||||
tmp_path = profile_env
|
||||
default_home = tmp_path / ".hermes"
|
||||
(default_home / "config.yaml").write_text(f"memory:\n provider: {provider!r}\n")
|
||||
(tmp_path / "outside").mkdir()
|
||||
(tmp_path / "outside" / "config.json").write_text("{}")
|
||||
(default_home / "a").mkdir()
|
||||
(default_home / "a" / "b").mkdir()
|
||||
(default_home / "a" / "b" / "config.json").write_text("{}")
|
||||
|
||||
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
|
||||
|
||||
assert not (profile_dir / "a").exists()
|
||||
assert not (profile_dir.parent / "outside").exists()
|
||||
assert not (profile_dir / "hind sight").exists()
|
||||
|
||||
def test_clone_sync_imports_carries_manifest_but_never_links_profiles(self, profile_env):
|
||||
"""--sync-imports copies import-sync.json (a pointer at EXTERNAL agent trees) and nothing
|
||||
else changes: the clone still gets its own config/skills copies, never a live link."""
|
||||
|
||||
@@ -80,7 +80,7 @@ Creates a new profile.
|
||||
| Argument / Option | Description |
|
||||
|-------------------|-------------|
|
||||
| `<name>` | Name for the new profile. Must be a valid directory name (alphanumeric, hyphens, underscores). |
|
||||
| `--clone` | Copy `config.yaml`, `.env`, `SOUL.md`, skills, and the curated `memories/MEMORY.md` / `memories/USER.md` from the current profile. Sessions, `state.db` and cron jobs are not copied. |
|
||||
| `--clone` | Copy `config.yaml`, `.env`, `SOUL.md`, skills, the curated `memories/MEMORY.md` / `memories/USER.md`, and the active `memory.provider`'s own config (`<provider>/` or `<provider>.json`, e.g. `hindsight/config.json`) from the current profile. Sessions, `state.db` and cron jobs are not copied. |
|
||||
| `--clone-all` | Copy everything (config, memories, skills, plugins) from the current profile. Excludes per-profile history: sessions, `state.db`, backups, state-snapshots, checkpoints — and cron jobs, which stay bound to the source profile (a clone that inherited them would fire every job twice). When the source is the default profile, the machine-scoped local-model trees (`models/`, `runtimes/`, `node/`) are also skipped — the same trees `hermes backup` excludes. |
|
||||
| `--clone-from <profile>` | Clone config/skills/SOUL from a specific profile instead of the current one. Implies `--clone` unless paired with `--clone-all`. |
|
||||
| `--no-alias` | Skip wrapper script creation. |
|
||||
|
||||
@@ -80,7 +80,7 @@ You can also set or auto-generate the description later with `hermes profile des
|
||||
hermes profile create work --clone
|
||||
```
|
||||
|
||||
Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
|
||||
Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. If `config.yaml` selects an external memory provider (`memory.provider`), that provider's own config travels too — its `<provider>/` directory or `<provider>.json` under the profile home, e.g. `hindsight/config.json` — so the clone's memory is available instead of silently off; a cloned `local_embedded` hindsight config still shares the source's embedded daemon and bank until you give the clone its own hindsight `profile`/`bank_id` ([#81815](https://github.com/NousResearch/hermes-agent/issues/81815)). Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
|
||||
|
||||
#### Keep a clone's imported agent setups synced (`--sync-imports`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user