fix: comprehensive default profile export exclusions and import guard
- Add _DEFAULT_EXPORT_EXCLUDE_ROOT constant with 25+ entries to exclude from default profile exports: repo checkout (hermes-agent), worktrees, databases (state.db), caches, runtime state, logs, binaries - Add _default_export_ignore() with root-level and universal exclusions (__pycache__, *.sock, *.tmp at any depth) - Remove redundant shutil/tempfile imports from contributor's if-block - Block import_profile() from accepting 'default' as target name with clear guidance to use --name - Add 7 tests covering: archive creation, inclusion of profile data, exclusion of infrastructure, nested __pycache__ exclusion, import rejection without --name, import rejection with --name default, full export-import roundtrip with a different name Addresses review feedback on PR #4370.
This commit is contained in:
@@ -58,6 +58,32 @@ _CLONE_ALL_STRIP = [
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
# Directories/files to exclude when exporting the default (~/.hermes) profile.
|
||||
# The default profile contains infrastructure (repo checkout, worktrees, DBs,
|
||||
# caches, binaries) that named profiles don't have. We exclude those so the
|
||||
# export is a portable, reasonable-size archive of actual profile data.
|
||||
_DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({
|
||||
# Infrastructure
|
||||
"hermes-agent", # repo checkout (multi-GB)
|
||||
".worktrees", # git worktrees
|
||||
"profiles", # other profiles — never recursive-export
|
||||
"bin", # installed binaries (tirith, etc.)
|
||||
"node_modules", # npm packages
|
||||
# Databases & runtime state
|
||||
"state.db", "state.db-shm", "state.db-wal",
|
||||
"hermes_state.db",
|
||||
"response_store.db", "response_store.db-shm", "response_store.db-wal",
|
||||
"gateway.pid", "gateway_state.json", "processes.json",
|
||||
"auth.lock", "active_profile", ".update_check",
|
||||
"errors.log",
|
||||
".hermes_history",
|
||||
# Caches (regenerated on use)
|
||||
"image_cache", "audio_cache", "document_cache",
|
||||
"browser_screenshots", "checkpoints",
|
||||
"sandboxes",
|
||||
"logs", # gateway logs
|
||||
})
|
||||
|
||||
# Names that cannot be used as profile aliases
|
||||
_RESERVED_NAMES = frozenset({
|
||||
"hermes", "default", "test", "tmp", "root", "sudo",
|
||||
@@ -685,11 +711,37 @@ def get_active_profile_name() -> str:
|
||||
# Export / Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def _ignore(directory: str, contents: list) -> set:
|
||||
ignored: set = set()
|
||||
for entry in contents:
|
||||
# Universal exclusions (any depth)
|
||||
if entry == "__pycache__" or entry.endswith((".sock", ".tmp")):
|
||||
ignored.add(entry)
|
||||
# npm lockfiles can appear at root
|
||||
elif entry in ("package.json", "package-lock.json"):
|
||||
ignored.add(entry)
|
||||
# Root-level exclusions
|
||||
if Path(directory) == root_dir:
|
||||
ignored.update(c for c in contents if c in _DEFAULT_EXPORT_EXCLUDE_ROOT)
|
||||
return ignored
|
||||
|
||||
return _ignore
|
||||
|
||||
|
||||
def export_profile(name: str, output_path: str) -> Path:
|
||||
"""Export a profile to a tar.gz archive.
|
||||
|
||||
Returns the output file path.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
validate_profile_name(name)
|
||||
profile_dir = get_profile_dir(name)
|
||||
if not profile_dir.is_dir():
|
||||
@@ -700,15 +752,15 @@ def export_profile(name: str, output_path: str) -> Path:
|
||||
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
|
||||
|
||||
if name == "default":
|
||||
import tempfile
|
||||
import shutil
|
||||
# The default profile IS ~/.hermes itself — its parent is ~/ and its
|
||||
# directory name is ".hermes", not "default". We stage a clean copy
|
||||
# under a temp dir so the archive contains ``default/...``.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_default_dir = Path(tmpdir) / "default"
|
||||
# Copy root profile contents to a dummy 'default' folder, ignoring other profiles and runtime state
|
||||
staged = Path(tmpdir) / "default"
|
||||
shutil.copytree(
|
||||
profile_dir,
|
||||
tmp_default_dir,
|
||||
ignore=shutil.ignore_patterns("profiles", "gateway.pid", "*.sock", "__pycache__")
|
||||
staged,
|
||||
ignore=_default_export_ignore(profile_dir),
|
||||
)
|
||||
result = shutil.make_archive(base, "gztar", tmpdir, "default")
|
||||
return Path(result)
|
||||
@@ -803,6 +855,15 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"Specify it explicitly: hermes profile import <archive> --name <name>"
|
||||
)
|
||||
|
||||
# Archives exported from the default profile have "default/" as top-level
|
||||
# dir. Importing as "default" would target ~/.hermes itself — disallow
|
||||
# that and guide the user toward a named profile.
|
||||
if inferred_name == "default":
|
||||
raise ValueError(
|
||||
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
|
||||
"Specify a different name: hermes profile import <archive> --name <name>"
|
||||
)
|
||||
|
||||
validate_profile_name(inferred_name)
|
||||
profile_dir = get_profile_dir(inferred_name)
|
||||
if profile_dir.exists():
|
||||
|
||||
@@ -488,6 +488,149 @@ class TestExportImport:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
export_profile("nonexistent", str(tmp_path / "out.tar.gz"))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Default profile export / import
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def test_export_default_creates_valid_archive(self, profile_env, tmp_path):
|
||||
"""Exporting the default profile produces a valid tar.gz."""
|
||||
default_dir = get_profile_dir("default")
|
||||
(default_dir / "config.yaml").write_text("model: test")
|
||||
|
||||
output = tmp_path / "export" / "default.tar.gz"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = export_profile("default", str(output))
|
||||
|
||||
assert Path(result).exists()
|
||||
assert tarfile.is_tarfile(str(result))
|
||||
|
||||
def test_export_default_includes_profile_data(self, profile_env, tmp_path):
|
||||
"""Profile data files end up in the archive."""
|
||||
default_dir = get_profile_dir("default")
|
||||
(default_dir / "config.yaml").write_text("model: test")
|
||||
(default_dir / ".env").write_text("KEY=val")
|
||||
(default_dir / "SOUL.md").write_text("Be nice.")
|
||||
mem_dir = default_dir / "memories"
|
||||
mem_dir.mkdir(exist_ok=True)
|
||||
(mem_dir / "MEMORY.md").write_text("remember this")
|
||||
|
||||
output = tmp_path / "export" / "default.tar.gz"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile("default", str(output))
|
||||
|
||||
with tarfile.open(str(output), "r:gz") as tf:
|
||||
names = tf.getnames()
|
||||
|
||||
assert "default/config.yaml" in names
|
||||
assert "default/.env" in names
|
||||
assert "default/SOUL.md" in names
|
||||
assert "default/memories/MEMORY.md" in names
|
||||
|
||||
def test_export_default_excludes_infrastructure(self, profile_env, tmp_path):
|
||||
"""Repo checkout, worktrees, profiles, databases are excluded."""
|
||||
default_dir = get_profile_dir("default")
|
||||
(default_dir / "config.yaml").write_text("ok")
|
||||
|
||||
# Create dirs/files that should be excluded
|
||||
for d in ("hermes-agent", ".worktrees", "profiles", "bin",
|
||||
"image_cache", "logs", "sandboxes", "checkpoints"):
|
||||
sub = default_dir / d
|
||||
sub.mkdir(exist_ok=True)
|
||||
(sub / "marker.txt").write_text("excluded")
|
||||
|
||||
for f in ("state.db", "gateway.pid", "gateway_state.json",
|
||||
"processes.json", "errors.log", ".hermes_history",
|
||||
"active_profile", ".update_check", "auth.lock"):
|
||||
(default_dir / f).write_text("excluded")
|
||||
|
||||
output = tmp_path / "export" / "default.tar.gz"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile("default", str(output))
|
||||
|
||||
with tarfile.open(str(output), "r:gz") as tf:
|
||||
names = tf.getnames()
|
||||
|
||||
# Config is present
|
||||
assert "default/config.yaml" in names
|
||||
|
||||
# Infrastructure excluded
|
||||
excluded_prefixes = [
|
||||
"default/hermes-agent", "default/.worktrees", "default/profiles",
|
||||
"default/bin", "default/image_cache", "default/logs",
|
||||
"default/sandboxes", "default/checkpoints",
|
||||
]
|
||||
for prefix in excluded_prefixes:
|
||||
assert not any(n.startswith(prefix) for n in names), \
|
||||
f"Expected {prefix} to be excluded but found it in archive"
|
||||
|
||||
excluded_files = [
|
||||
"default/state.db", "default/gateway.pid",
|
||||
"default/gateway_state.json", "default/processes.json",
|
||||
"default/errors.log", "default/.hermes_history",
|
||||
"default/active_profile", "default/.update_check",
|
||||
"default/auth.lock",
|
||||
]
|
||||
for f in excluded_files:
|
||||
assert f not in names, f"Expected {f} to be excluded"
|
||||
|
||||
def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_path):
|
||||
"""__pycache__ dirs are excluded even inside nested directories."""
|
||||
default_dir = get_profile_dir("default")
|
||||
(default_dir / "config.yaml").write_text("ok")
|
||||
nested = default_dir / "skills" / "my-skill" / "__pycache__"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "cached.pyc").write_text("bytecode")
|
||||
|
||||
output = tmp_path / "export" / "default.tar.gz"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile("default", str(output))
|
||||
|
||||
with tarfile.open(str(output), "r:gz") as tf:
|
||||
names = tf.getnames()
|
||||
|
||||
assert not any("__pycache__" 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")
|
||||
(default_dir / "config.yaml").write_text("ok")
|
||||
|
||||
archive = tmp_path / "export" / "default.tar.gz"
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile("default", str(archive))
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot import as 'default'"):
|
||||
import_profile(str(archive))
|
||||
|
||||
def test_import_default_with_explicit_default_name_raises(self, profile_env, tmp_path):
|
||||
"""Explicitly importing as 'default' is also rejected."""
|
||||
default_dir = get_profile_dir("default")
|
||||
(default_dir / "config.yaml").write_text("ok")
|
||||
|
||||
archive = tmp_path / "export" / "default.tar.gz"
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile("default", str(archive))
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot import as 'default'"):
|
||||
import_profile(str(archive), name="default")
|
||||
|
||||
def test_import_default_export_with_new_name_roundtrip(self, profile_env, tmp_path):
|
||||
"""Export default → import under a different name → data preserved."""
|
||||
default_dir = get_profile_dir("default")
|
||||
(default_dir / "config.yaml").write_text("model: opus")
|
||||
mem_dir = default_dir / "memories"
|
||||
mem_dir.mkdir(exist_ok=True)
|
||||
(mem_dir / "MEMORY.md").write_text("important fact")
|
||||
|
||||
archive = tmp_path / "export" / "default.tar.gz"
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
export_profile("default", str(archive))
|
||||
|
||||
imported = import_profile(str(archive), name="backup")
|
||||
assert imported.is_dir()
|
||||
assert (imported / "config.yaml").read_text() == "model: opus"
|
||||
assert (imported / "memories" / "MEMORY.md").read_text() == "important fact"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestProfileIsolation
|
||||
|
||||
Reference in New Issue
Block a user