mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +08:00
Compare commits
2 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439b2f3fff | ||
|
|
a0996e2eac |
@@ -58,6 +58,32 @@ _CLONE_ALL_STRIP = [
|
|||||||
"processes.json",
|
"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
|
# Names that cannot be used as profile aliases
|
||||||
_RESERVED_NAMES = frozenset({
|
_RESERVED_NAMES = frozenset({
|
||||||
"hermes", "default", "test", "tmp", "root", "sudo",
|
"hermes", "default", "test", "tmp", "root", "sudo",
|
||||||
@@ -685,11 +711,37 @@ def get_active_profile_name() -> str:
|
|||||||
# Export / Import
|
# 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:
|
def export_profile(name: str, output_path: str) -> Path:
|
||||||
"""Export a profile to a tar.gz archive.
|
"""Export a profile to a tar.gz archive.
|
||||||
|
|
||||||
Returns the output file path.
|
Returns the output file path.
|
||||||
"""
|
"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
validate_profile_name(name)
|
validate_profile_name(name)
|
||||||
profile_dir = get_profile_dir(name)
|
profile_dir = get_profile_dir(name)
|
||||||
if not profile_dir.is_dir():
|
if not profile_dir.is_dir():
|
||||||
@@ -698,6 +750,21 @@ def export_profile(name: str, output_path: str) -> Path:
|
|||||||
output = Path(output_path)
|
output = Path(output_path)
|
||||||
# shutil.make_archive wants the base name without extension
|
# shutil.make_archive wants the base name without extension
|
||||||
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
|
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
|
||||||
|
|
||||||
|
if name == "default":
|
||||||
|
# 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:
|
||||||
|
staged = Path(tmpdir) / "default"
|
||||||
|
shutil.copytree(
|
||||||
|
profile_dir,
|
||||||
|
staged,
|
||||||
|
ignore=_default_export_ignore(profile_dir),
|
||||||
|
)
|
||||||
|
result = shutil.make_archive(base, "gztar", tmpdir, "default")
|
||||||
|
return Path(result)
|
||||||
|
|
||||||
result = shutil.make_archive(base, "gztar", str(profile_dir.parent), name)
|
result = shutil.make_archive(base, "gztar", str(profile_dir.parent), name)
|
||||||
return Path(result)
|
return Path(result)
|
||||||
|
|
||||||
@@ -788,6 +855,15 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
|||||||
"Specify it explicitly: hermes profile import <archive> --name <name>"
|
"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)
|
validate_profile_name(inferred_name)
|
||||||
profile_dir = get_profile_dir(inferred_name)
|
profile_dir = get_profile_dir(inferred_name)
|
||||||
if profile_dir.exists():
|
if profile_dir.exists():
|
||||||
|
|||||||
@@ -488,6 +488,149 @@ class TestExportImport:
|
|||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
export_profile("nonexistent", str(tmp_path / "out.tar.gz"))
|
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
|
# TestProfileIsolation
|
||||||
|
|||||||
Reference in New Issue
Block a user