fix(profiles): preserve symlinks during profile export

shutil.copytree() defaults to symlinks=False which follows symlinks and
crashes on broken ones.  In Docker/custom HERMES_HOME deployments,
unrelated directories may contain stale symlinks that break export.

Add symlinks=True to both copytree() calls in export_profile() so
broken symlinks are preserved as symlink entries in the archive.

Fixes #58394
This commit is contained in:
liuhao1024 2026-07-05 01:26:44 +08:00 committed by Teknium
parent 9ae17b8ac5
commit b6b9bcd2a1
2 changed files with 29 additions and 0 deletions

View file

@ -1890,6 +1890,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 +1903,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)

View file

@ -1385,6 +1385,33 @@ class TestExportImport:
assert not any("__pycache__" in n for n in names)
def test_export_default_handles_broken_symlinks(self, profile_env, tmp_path):
"""Export succeeds when the profile directory contains broken symlinks.
In Docker/custom HERMES_HOME deployments, unrelated directories may
contain stale symlinks. copytree must not follow them.
"""
default_dir = get_profile_dir("default")
(default_dir / "config.yaml").write_text("ok")
# Create a broken symlink (target does not exist)
(default_dir / "broken_link").symlink_to("/nonexistent/path")
# Create a valid symlink for comparison
(default_dir / "valid_target.txt").write_text("real data")
(default_dir / "valid_link").symlink_to(default_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 = tf.getnames()
# Broken symlink is preserved as a symlink entry
assert any("broken_link" in n for n in names)
# Valid symlink and its target are both present
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")