# NousResearch/hermes-agent · Profile symlink handling and RecursionError fixes

## Open now

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

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #58506. Verify it addresses your case.
- **Recommended:** read PR #58506 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-58506`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #11560: hermes profile --clone-all crashes with RecursionError on recursive symlink

## Prior & competing PRs

- **#58506: fix(profiles): export allow-list + symlink preservation across export and clone paths (3-PR salvage)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/profiles.py`, `scripts/release.py`, `tests/hermes_cli/test_profiles.py`
  - fix hunks:
  - `hermes_cli/profiles.py`

    ```diff
    @@ -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",
    ```

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

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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:
    ```

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

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

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

    ```diff
    @@ -50,6 +50,7 @@
         "Jigoooo@users.noreply.github.com": "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)
         "huanshan5195@users.noreply.github.com": "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`

    ```diff
    @@ -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")
    ```
- **#11573: fix(profiles): preserve symlinks when cloning and exporting profiles**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `b7192b1cb`, `#58506`, `#58397`, `#58445`
- **#11829: fix(profiles): pass symlinks=True to copytree to prevent RecursionError**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#11560`, `#11573`

## Fix status

- Merged to main in PR #58506 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `hermes_cli`
