diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index 0eccafdde6..4289b036c0 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -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.") diff --git a/hermes_cli/profile_memory_config.py b/hermes_cli/profile_memory_config.py new file mode 100644 index 0000000000..36bb099b09 --- /dev/null +++ b/hermes_cli/profile_memory_config.py @@ -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 ``//`` +directory (hindsight) or a flat ``/.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 ``/`` and/or ``.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 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 9b52b934d4..e161d15b5f 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 3808c6f11e..93ab79825e 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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 + ``/`` dir / ``.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.""" diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 8e34e33136..06244e462c 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -80,7 +80,7 @@ Creates a new profile. | Argument / Option | Description | |-------------------|-------------| | `` | 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 (`/` or `.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 ` | 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. | diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 006e31941b..345b74f282 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -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 `/` directory or `.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`)