NousResearch/hermes-agent

Profile symlink handling and RecursionError fixes

Cluster 11560 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #58506 on 5 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #58506. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #58506. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs address crashes and incorrect behavior when profile clone and export operations encounter symlinks, specifically a RecursionError caused by recursive symlinks and missing symlink preservation across clone and export paths.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #58506 fix(profiles): export allow-list + symlink preservation across export and clone paths (3-PR salvage)

    merged to main

    files: hermes_cli/profiles.py, scripts/release.py, tests/hermes_cli/test_profiles.py

    Show the diff
    • hermes_cli/profiles.py
      @@ -224,6 +224,25 @@ def _ignore(directory: str, names: List[str]) -> List[str]:
           "logs",                 # gateway logs
       })
       
      +# Allow-list for ``export_profile("default")``: when HERMES_HOME equals the
      +# cwd (Docker/custom deployments), the default profile home is the working
      +# directory and contains arbitrary user files that should NOT be bundled
      +# into the export. The set below identifies the *known Hermes profile
      +# artifacts* at the root of HERMES_HOME; everything else is excluded.
      +# Sensitive runtime infrastructure (``state.db``, ``logs/``, ``auth.*``,
      +# other profiles) is intentionally *not* in this list so the export stays
      +# a portable, credential-free snapshot of the user-facing surface
      +# (#58394). Add new artifacts here when introduced in ``hermes_constants``.
      +_DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({
      +    # Configuration / persona
      +    "config.yaml", "SOUL.md", "MEMORY.md", "USER.md", "todo.json",
      +    "system_prompt.md", "AGENTS.md", "CLAUDE.md", ".cursorrules",
      +    # User-facing skill, cron, and session artifacts
      +    "skills", "cron", "scripts", "sessions",
      +    # Plugin / memory surfaces (per-profile overrides live here)
      +    "plugins", "memories", "knowledge", "preferences",
      +})
      +
       # Names that cannot be used as profile aliases
       _RESERVED_NAMES = frozenset({
           "hermes", "default", "test", "tmp", "root", "sudo",
      @@ -1042,6 +1061,7 @@ def create_profile(
               shutil.copytree(
                   source_dir,
                   profile_dir,
      +            symlinks=True,
                   ignore=_clone_all_copytree_ignore(source_dir),
               )
               # Strip runtime files
      @@ -1076,7 +1096,7 @@ def create_profile(
                   # same agent capabilities as the source profile.
                   source_skills = source_dir / "skills"
                   if source_skills.is_dir():
      -                shutil.copytree(source_skills, profile_dir / "skills", dirs_exist_ok=True)
      +                shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True)
       
                   # Clone memory and other subdirectory files
                   for relpath in _CLONE_SUBDIR_FILES:
      @@ -1843,8 +1863,18 @@ def get_active_profile_name() -> str:
       def _default_export_ignore(root_dir: Path):
           """Return an *ignore* callable for :func:`shutil.copytree`.
       
      -    At the root level it excludes everything in ``_DEFAULT_EXPORT_EXCLUDE_ROOT``.
      -    At all levels it excludes ``__pycache__``, sockets, and temp files.
      +    Two-tier filtering:
      +
      +    * **Root-level allow-list** — only entries whose name appears in
      +      ``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as
      +      an unrelated ``x11-dev/`` directory in a Docker deployment where
      +      HERMES_HOME equals the cwd) is excluded. Blacklisting was tried
      +      first and proved unable to anticipate every non-Hermes file the
      +      user may have lying alongside HERMES_HOME (#58394).
      +    * **Universal exclusions at any depth** — ``__pycache__``, sockets,
      +      temp files; plus npm lockfiles, which may appear at the root.
      +
      +    All other profile artifacts are copied through untouched.
           """
       
           def _ignore(directory: str, contents: list) -> set:
      @@ -1856,9 +1886,12 @@ def _ignore(directory: str, contents: list) -> set:
                   # npm lockfiles can appear at root
                   elif entry in {"package.json", "package-lock.json"}:
                       ignored.add(entry)
      -        # Root-level exclusions
      +        # Root-level allow-list: drop everything that isn't a known
      +        # Hermes profile artifact.
               if Path(directory) == root_dir:
      -            ignored.update(c for c in contents if c in _DEFAULT_EXPORT_EXCLUDE_ROOT)
      +            ignored.update(
      +                entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT
      +            )
               return ignored
       
           return _ignore
      @@ -1890,6 +1923,7 @@ def export_profile(name: str, output_path: str) -> Path:
                   shutil.copytree(
                       profile_dir,
                       staged,
      +                symlinks=True,
                       ignore=_default_export_ignore(profile_dir),
                   )
                   result = shutil.make_archive(base, "gztar", tmpdir, "default")
      @@ -1902,6 +1936,7 @@ def export_profile(name: str, output_path: str) -> Path:
               shutil.copytree(
                   profile_dir,
                   staged,
      +            symlinks=True,
                   ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),
               )
               result = shutil.make_archive(base, "gztar", tmpdir, canon)
    • scripts/release.py
      @@ -50,6 +50,7 @@
           "[email protected]": "Jigoooo",  # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry)
           "[redacted:email]": "ostravajih",  # PR #58374 salvage (poolside: coerce integer finish_reason and tool_call id to strings)
           "[redacted:email]": "sahil-shubham",  # PR #58448 salvage (whatsapp_cloud: honor documented WHATSAPP_CLOUD_ALLOWED_USERS / ALLOW_ALL_USERS in the DM intake gate)
      +    "[redacted:email]": "Ahmett101",  # PR #58445 salvage (profiles: allowlist default-export roots + preserve symlinks)
           "[redacted:email]": "msh01",  # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).
           "[redacted:email]": "danilofalcao",  # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)
           "[email protected]": "huanshan5195",  # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level)
    • tests/hermes_cli/test_profiles.py
      @@ -1385,6 +1385,84 @@ def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_pat
       
               assert not any("__pycache__" in n for n in names)
       
      +    def test_export_default_uses_allowlist_for_unrelated_dirs(self, profile_env, tmp_path):
      +        """Unrelated directories under HERMES_HOME are excluded by allow-list (#58394).
      +
      +        Docker/custom deployments often set HERMES_HOME to a working
      +        directory that also contains unrelated user projects (``x11-dev/``,
      +        etc.).  The root-level allow-list filters those out so only known
      +        Hermes artifacts end up in the archive. Replaces the old
      +        exhaustive blacklist.
      +        """
      +        default_dir = get_profile_dir("default")
      +        (default_dir / "config.yaml").write_text("ok")
      +        (default_dir / "SOUL.md").write_text("soul")
      +        # Allowed subdirectory with content
      +        (default_dir / "skills" / "demo").mkdir(parents=True)
      +        (default_dir / "skills" / "demo" / "SKILL.md").write_text("hi")
      +        # Unrelated directory — should NOT appear in the archive
      +        unrelated = default_dir / "x11-dev" / "usr" / "lib"
      +        unrelated.mkdir(parents=True)
      +        (unrelated / "libXi.so").write_text("data")
      +
      +        output = tmp_path / "export" / "default.tar.gz"
      +        output.parent.mkdir(parents=True, exist_ok=True)
      +        result = export_profile("default", str(output))
      +
      +        with tarfile.open(str(result), "r:gz") as tf:
      +            names = set(tf.getnames())
      +
      +        # Allowed artifacts present
      +        assert any(n.endswith("config.yaml") for n in names)
      +        assert any(n.endswith("SOUL.md") for n in names)
      +        assert any(n.endswith("skills/demo/SKILL.md") for n in names)
      +        # Unrelated artifact excluded
      +        assert not any("x11-dev" in n for n in names)
      +        assert not any("libXi.so" in n for n in names)
      +
      +    def test_export_default_handles_broken_symlinks(self, profile_env, tmp_path):
      +        """Broken symlinks inside allowed artifacts are preserved, not crashed (#58394).
      +
      +        ``shutil.copytree``'s default is ``symlinks=False``, which follows
      +        symlinks and crashes on broken ones. Use ``symlinks=True`` so stale
      +        symlinks inside *allowed* artifacts (e.g. ``skills/``) survive as
      +        symlinks; the link and its target are both retained.
      +        """
      +        default_dir = get_profile_dir("default")
      +        (default_dir / "config.yaml").write_text("ok")
      +        # Place broken symlink *inside* the allowed ``skills/`` tree so the
      +        # root-level allow-list passes the directory through; the
      +        # symlinks=True flag must then preserve the link instead of
      +        # following and crashing.
      +        broken_dir = default_dir / "skills" / "with-broken-links"
      +        broken_dir.mkdir(parents=True)
      +        (broken_dir / "broken_link").symlink_to("/nonexistent/path")
      +        # Valid symlink for comparison
      +        (broken_dir / "valid_target.txt").write_text("real data")
      +        (broken_dir / "valid_link").symlink_to(
      +            broken_dir / "valid_target.txt"
      +        )
      +
      +        output = tmp_path / "export" / "default.tar.gz"
      +        output.parent.mkdir(parents=True, exist_ok=True)
      +        result = export_profile("default", str(output))
      +
      +        assert result.exists()
      +        with tarfile.open(str(result), "r:gz") as tf:
      +            names = set(tf.getnames())
      +        # Allowed artifact survived
      +        assert any(n.endswith("config.yaml") for n in names)
      +        # Broken symlink inside an allowed dir was preserved as a symlink
      +        # (without crashing) — tar entry name recorded as the link path.
      +        assert any(
      +            "with-broken-links/broken_link" in n for n in names
      +        ), (
      +            f"broken_link should survive; tarfile names: {sorted(names)[:30]}"
      +        )
      +        # Valid symlink + target also kept
      +        assert any("valid_link" in n for n in names)
      +        assert any("valid_target.txt" in n for n in names)
      +
           def test_import_default_without_name_raises(self, profile_env, tmp_path):
               """Importing a default export without --name gives clear guidance."""
               default_dir = get_profile_dir("default")

2 PRs closed without merging

From the close comments: duplicate 1 · merged via another change 1

  • #11573 fix(profiles): preserve symlinks when cloning and exporting profiles · merged via another change → b7192b1, #58506, #58397, #58445
  • #11829 fix(profiles): pass symlinks=True to copytree to prevent RecursionError · duplicate → #11560, #11573
Closed issues · 1
  • Closed#11560hermes profile --clone-all crashes with RecursionError on recursive symlink
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#58506
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.