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:
teknium1
2026-09-23 04:36:12 -07:00
committed by Teknium
parent fd9498122e
commit 0b74f322e2
6 changed files with 119 additions and 2 deletions

View File

@@ -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.")

View 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

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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. |

View File

@@ -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`)