{"repo":"NousResearch/hermes-agent","public_id":"11560","display_label":"Profile symlink handling and RecursionError fixes","summary":"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.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58506,"merged_at":"2026-07-05T07:48:51+00:00"},"resolution":{"state":"resolved","by":"pr-58506"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T07:48:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":11560,"pr_number":58506,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-05T07:48:51+00:00"},{"issue_number":58394,"pr_number":58506,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-05T07:48:51+00:00"}],"members":[{"kind":"issue","number":11560,"title":"hermes profile --clone-all crashes with RecursionError on recursive symlink","url":null,"state":"closed"},{"kind":"pr","number":11573,"title":"fix(profiles): preserve symlinks when cloning and exporting profiles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b7192b1cb","number":0},{"kind":"pr","value":"#58506","number":58506},{"kind":"pr","value":"#58397","number":58397},{"kind":"pr","value":"#58445","number":58445}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11829,"title":"fix(profiles): pass symlinks=True to copytree to prevent RecursionError","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11560","number":11560},{"kind":"pr","value":"#11573","number":11573}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58506,"title":"fix(profiles): export allow-list + symlink preservation across export and clone paths (3-PR salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/profiles.py","scripts/release.py","tests/hermes_cli/test_profiles.py"],"fix_hunks":[{"file":"hermes_cli/profiles.py","hunks":["@@ -224,6 +224,25 @@ def _ignore(directory: str, names: List[str]) -> List[str]:\n     \"logs\",                 # gateway logs\n })\n \n+# Allow-list for ``export_profile(\"default\")``: when HERMES_HOME equals the\n+# cwd (Docker/custom deployments), the default profile home is the working\n+# directory and contains arbitrary user files that should NOT be bundled\n+# into the export. The set below identifies the *known Hermes profile\n+# artifacts* at the root of HERMES_HOME; everything else is excluded.\n+# Sensitive runtime infrastructure (``state.db``, ``logs/``, ``auth.*``,\n+# other profiles) is intentionally *not* in this list so the export stays\n+# a portable, credential-free snapshot of the user-facing surface\n+# (#58394). Add new artifacts here when introduced in ``hermes_constants``.\n+_DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({\n+    # Configuration / persona\n+    \"config.yaml\", \"SOUL.md\", \"MEMORY.md\", \"USER.md\", \"todo.json\",\n+    \"system_prompt.md\", \"AGENTS.md\", \"CLAUDE.md\", \".cursorrules\",\n+    # User-facing skill, cron, and session artifacts\n+    \"skills\", \"cron\", \"scripts\", \"sessions\",\n+    # Plugin / memory surfaces (per-profile overrides live here)\n+    \"plugins\", \"memories\", \"knowledge\", \"preferences\",\n+})\n+\n # Names that cannot be used as profile aliases\n _RESERVED_NAMES = frozenset({\n     \"hermes\", \"default\", \"test\", \"tmp\", \"root\", \"sudo\",","@@ -1042,6 +1061,7 @@ def create_profile(\n         shutil.copytree(\n             source_dir,\n             profile_dir,\n+            symlinks=True,\n             ignore=_clone_all_copytree_ignore(source_dir),\n         )\n         # Strip runtime files","@@ -1076,7 +1096,7 @@ def create_profile(\n             # same agent capabilities as the source profile.\n             source_skills = source_dir / \"skills\"\n             if source_skills.is_dir():\n-                shutil.copytree(source_skills, profile_dir / \"skills\", dirs_exist_ok=True)\n+                shutil.copytree(source_skills, profile_dir / \"skills\", symlinks=True, dirs_exist_ok=True)\n \n             # Clone memory and other subdirectory files\n             for relpath in _CLONE_SUBDIR_FILES:","@@ -1843,8 +1863,18 @@ def get_active_profile_name() -> str:\n def _default_export_ignore(root_dir: Path):\n     \"\"\"Return an *ignore* callable for :func:`shutil.copytree`.\n \n-    At the root level it excludes everything in ``_DEFAULT_EXPORT_EXCLUDE_ROOT``.\n-    At all levels it excludes ``__pycache__``, sockets, and temp files.\n+    Two-tier filtering:\n+\n+    * **Root-level allow-list** — only entries whose name appears in\n+      ``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as\n+      an unrelated ``x11-dev/`` directory in a Docker deployment where\n+      HERMES_HOME equals the cwd) is excluded. Blacklisting was tried\n+      first and proved unable to anticipate every non-Hermes file the\n+      user may have lying alongside HERMES_HOME (#58394).\n+    * **Universal exclusions at any depth** — ``__pycache__``, sockets,\n+      temp files; plus npm lockfiles, which may appear at the root.\n+\n+    All other profile artifacts are copied through untouched.\n     \"\"\"\n \n     def _ignore(directory: str, contents: list) -> set:","@@ -1856,9 +1886,12 @@ def _ignore(directory: str, contents: list) -> set:\n             # npm lockfiles can appear at root\n             elif entry in {\"package.json\", \"package-lock.json\"}:\n                 ignored.add(entry)\n-        # Root-level exclusions\n+        # Root-level allow-list: drop everything that isn't a known\n+        # Hermes profile artifact.\n         if Path(directory) == root_dir:\n-            ignored.update(c for c in contents if c in _DEFAULT_EXPORT_EXCLUDE_ROOT)\n+            ignored.update(\n+                entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT\n+            )\n         return ignored\n \n     return _ignore","@@ -1890,6 +1923,7 @@ def export_profile(name: str, output_path: str) -> Path:\n             shutil.copytree(\n                 profile_dir,\n                 staged,\n+                symlinks=True,\n                 ignore=_default_export_ignore(profile_dir),\n             )\n             result = shutil.make_archive(base, \"gztar\", tmpdir, \"default\")","@@ -1902,6 +1936,7 @@ def export_profile(name: str, output_path: str) -> Path:\n         shutil.copytree(\n             profile_dir,\n             staged,\n+            symlinks=True,\n             ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),\n         )\n         result = shutil.make_archive(base, \"gztar\", tmpdir, canon)"]},{"file":"scripts/release.py","hunks":["@@ -50,6 +50,7 @@\n     \"Jigoooo@users.noreply.github.com\": \"Jigoooo\",  # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry)\n     \"[redacted:email]\": \"ostravajih\",  # PR #58374 salvage (poolside: coerce integer finish_reason and tool_call id to strings)\n     \"[redacted:email]\": \"sahil-shubham\",  # PR #58448 salvage (whatsapp_cloud: honor documented WHATSAPP_CLOUD_ALLOWED_USERS / ALLOW_ALL_USERS in the DM intake gate)\n+    \"[redacted:email]\": \"Ahmett101\",  # PR #58445 salvage (profiles: allowlist default-export roots + preserve symlinks)\n     \"[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).\n     \"[redacted:email]\": \"danilofalcao\",  # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)\n     \"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)"]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -1385,6 +1385,84 @@ def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_pat\n \n         assert not any(\"__pycache__\" in n for n in names)\n \n+    def test_export_default_uses_allowlist_for_unrelated_dirs(self, profile_env, tmp_path):\n+        \"\"\"Unrelated directories under HERMES_HOME are excluded by allow-list (#58394).\n+\n+        Docker/custom deployments often set HERMES_HOME to a working\n+        directory that also contains unrelated user projects (``x11-dev/``,\n+        etc.).  The root-level allow-list filters those out so only known\n+        Hermes artifacts end up in the archive. Replaces the old\n+        exhaustive blacklist.\n+        \"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"ok\")\n+        (default_dir / \"SOUL.md\").write_text(\"soul\")\n+        # Allowed subdirectory with content\n+        (default_dir / \"skills\" / \"demo\").mkdir(parents=True)\n+        (default_dir / \"skills\" / \"demo\" / \"SKILL.md\").write_text(\"hi\")\n+        # Unrelated directory — should NOT appear in the archive\n+        unrelated = default_dir / \"x11-dev\" / \"usr\" / \"lib\"\n+        unrelated.mkdir(parents=True)\n+        (unrelated / \"libXi.so\").write_text(\"data\")\n+\n+        output = tmp_path / \"export\" / \"default.tar.gz\"\n+        output.parent.mkdir(parents=True, exist_ok=True)\n+        result = export_profile(\"default\", str(output))\n+\n+        with tarfile.open(str(result), \"r:gz\") as tf:\n+            names = set(tf.getnames())\n+\n+        # Allowed artifacts present\n+        assert any(n.endswith(\"config.yaml\") for n in names)\n+        assert any(n.endswith(\"SOUL.md\") for n in names)\n+        assert any(n.endswith(\"skills/demo/SKILL.md\") for n in names)\n+        # Unrelated artifact excluded\n+        assert not any(\"x11-dev\" in n for n in names)\n+        assert not any(\"libXi.so\" in n for n in names)\n+\n+    def test_export_default_handles_broken_symlinks(self, profile_env, tmp_path):\n+        \"\"\"Broken symlinks inside allowed artifacts are preserved, not crashed (#58394).\n+\n+        ``shutil.copytree``'s default is ``symlinks=False``, which follows\n+        symlinks and crashes on broken ones. Use ``symlinks=True`` so stale\n+        symlinks inside *allowed* artifacts (e.g. ``skills/``) survive as\n+        symlinks; the link and its target are both retained.\n+        \"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"ok\")\n+        # Place broken symlink *inside* the allowed ``skills/`` tree so the\n+        # root-level allow-list passes the directory through; the\n+        # symlinks=True flag must then preserve the link instead of\n+        # following and crashing.\n+        broken_dir = default_dir / \"skills\" / \"with-broken-links\"\n+        broken_dir.mkdir(parents=True)\n+        (broken_dir / \"broken_link\").symlink_to(\"/nonexistent/path\")\n+        # Valid symlink for comparison\n+        (broken_dir / \"valid_target.txt\").write_text(\"real data\")\n+        (broken_dir / \"valid_link\").symlink_to(\n+            broken_dir / \"valid_target.txt\"\n+        )\n+\n+        output = tmp_path / \"export\" / \"default.tar.gz\"\n+        output.parent.mkdir(parents=True, exist_ok=True)\n+        result = export_profile(\"default\", str(output))\n+\n+        assert result.exists()\n+        with tarfile.open(str(result), \"r:gz\") as tf:\n+            names = set(tf.getnames())\n+        # Allowed artifact survived\n+        assert any(n.endswith(\"config.yaml\") for n in names)\n+        # Broken symlink inside an allowed dir was preserved as a symlink\n+        # (without crashing) — tar entry name recorded as the link path.\n+        assert any(\n+            \"with-broken-links/broken_link\" in n for n in names\n+        ), (\n+            f\"broken_link should survive; tarfile names: {sorted(names)[:30]}\"\n+        )\n+        # Valid symlink + target also kept\n+        assert any(\"valid_link\" in n for n in names)\n+        assert any(\"valid_target.txt\" in n for n in names)\n+\n     def test_import_default_without_name_raises(self, profile_env, tmp_path):\n         \"\"\"Importing a default export without --name gives clear guidance.\"\"\"\n         default_dir = get_profile_dir(\"default\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}