--clone carried memory.provider (e.g. hindsight) in config.yaml but not the provider's own config under the profile home (hindsight/config.json, mem0.json, ...), so the clone booted with the provider selected and silently unavailable. Copy the ACTIVE provider's <provider>/ dir and/or <provider>.json by the same convention the dashboard memory-provider routers read, guard the name against path traversal, tighten copies to 0600 (they can hold an API key), and say so in the CLI notice. Convention-based on purpose: hindsight is a catalog plugin now, so a hook the plugin must implement could not fix the reported case, and no provider module is imported during profile create. Supersedes #43107 (credit @bionicbutterfly13 for the direction).
1909 lines
85 KiB
Python
1909 lines
85 KiB
Python
"""Comprehensive tests for hermes_cli.profiles module.
|
|
|
|
Tests cover: validation, directory resolution, CRUD operations, active profile
|
|
management, export/import, renaming, alias collision checks, profile isolation,
|
|
and shell completion generation.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import stat
|
|
import sys
|
|
import tarfile
|
|
import types
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from hermes_cli import profiles
|
|
from hermes_cli.profiles import (
|
|
_clone_all_copytree_ignore,
|
|
normalize_profile_name,
|
|
validate_profile_name,
|
|
get_profile_dir,
|
|
create_profile,
|
|
delete_profile,
|
|
list_profiles,
|
|
set_active_profile,
|
|
get_active_profile,
|
|
get_active_profile_name,
|
|
resolve_profile_env,
|
|
check_alias_collision,
|
|
create_wrapper_script,
|
|
remove_wrapper_script,
|
|
rename_profile,
|
|
export_profile,
|
|
_get_default_hermes_home,
|
|
NO_BUNDLED_SKILLS_MARKER,
|
|
backfill_profile_envs,
|
|
profiles_to_serve,
|
|
)
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixture: redirect Path.home() and HERMES_HOME for profile tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture()
|
|
def profile_env(tmp_path, monkeypatch):
|
|
"""Set up an isolated environment for profile tests.
|
|
|
|
* Path.home() -> tmp_path (so _get_profiles_root() = tmp_path/.hermes/profiles)
|
|
* HERMES_HOME -> tmp_path/.hermes (so get_hermes_home() agrees)
|
|
* Creates the bare-minimum ~/.hermes directory.
|
|
"""
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
return tmp_path
|
|
|
|
|
|
# ===================================================================
|
|
# TestValidateProfileName
|
|
# ===================================================================
|
|
|
|
class TestNormalizeProfileName:
|
|
"""Tests for normalize_profile_name()."""
|
|
|
|
def test_title_case_normalized(self):
|
|
assert normalize_profile_name("Jules") == "jules"
|
|
assert normalize_profile_name(" Librarian ") == "librarian"
|
|
|
|
|
|
class TestValidateProfileName:
|
|
"""Tests for validate_profile_name()."""
|
|
|
|
@pytest.mark.parametrize("name", ["coder", "work-bot", "a1", "my_agent"])
|
|
def test_valid_names_accepted(self, name):
|
|
# Should not raise
|
|
validate_profile_name(name)
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"])
|
|
def test_invalid_names_rejected(self, name):
|
|
with pytest.raises(ValueError):
|
|
validate_profile_name(name)
|
|
|
|
|
|
# ===================================================================
|
|
# TestGetProfileDir
|
|
# ===================================================================
|
|
|
|
class TestGetProfileDir:
|
|
"""Tests for get_profile_dir()."""
|
|
|
|
def test_default_returns_hermes_home(self, profile_env):
|
|
tmp_path = profile_env
|
|
result = get_profile_dir("default")
|
|
assert result == tmp_path / ".hermes"
|
|
|
|
@pytest.mark.parametrize("name", ["..", "../outside", "../../tmp", "a/b", "a\\b", ".hidden", "has space"])
|
|
def test_traversal_and_invalid_names_rejected(self, name, profile_env):
|
|
# The name becomes a path component under profiles/; invalid ids must
|
|
# raise instead of escaping the root.
|
|
with pytest.raises(ValueError):
|
|
get_profile_dir(name)
|
|
|
|
@pytest.mark.parametrize("name", ["..", "../outside", "a/b"])
|
|
def test_profile_exists_false_for_invalid_names(self, name, profile_env):
|
|
assert profiles.profile_exists(name) is False
|
|
|
|
|
|
# ===================================================================
|
|
# TestCreateProfile
|
|
# ===================================================================
|
|
|
|
class TestCreateProfile:
|
|
"""Tests for create_profile()."""
|
|
|
|
|
|
def test_seeds_placeholder_env_file(self, profile_env):
|
|
"""Fresh profiles get their own .env (owner-only) so channel/env
|
|
writes are profile-scoped from day one instead of falling through
|
|
to the shell environment / root install."""
|
|
import stat
|
|
profile_dir = create_profile("coder", no_alias=True)
|
|
env_path = profile_dir / ".env"
|
|
assert env_path.exists()
|
|
content = env_path.read_text(encoding="utf-8")
|
|
# Placeholder only — no credentials leak in from anywhere.
|
|
assert all(
|
|
line.startswith("#") or not line.strip()
|
|
for line in content.splitlines()
|
|
)
|
|
mode = stat.S_IMODE(env_path.stat().st_mode)
|
|
assert mode == 0o600
|
|
|
|
|
|
def test_fresh_profile_inherits_a_usable_model(self, profile_env):
|
|
"""A profile created without a clone source still resolves a provider.
|
|
|
|
Without this it gets no config.yaml at all, so its very first turn dies
|
|
with "No LLM provider configured" — created, but unable to run. Fresh
|
|
means fresh skills and SOUL, not unreachable.
|
|
"""
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text(
|
|
"model:\n provider: nous\n default: some/model\n"
|
|
)
|
|
|
|
profile_dir = create_profile("coder", no_alias=True)
|
|
|
|
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text())
|
|
assert cfg["model"]["provider"] == "nous"
|
|
assert cfg["model"]["default"] == "some/model"
|
|
|
|
|
|
def test_fresh_profile_inherits_its_custom_provider_gateway(self, profile_env):
|
|
"""The inherited model may point at a custom `providers:` gateway (self-hosted / local
|
|
endpoint). Copying `model` alone left the new bot with `model.provider: my-gateway` and
|
|
"Unknown provider 'my-gateway'" on its first turn (#101885 / #94071 class); the provider
|
|
definition must travel with the model it backs, and nothing else from `providers:` does.
|
|
"""
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text(
|
|
"model:\n provider: my-gateway\n default: my-finetune\n"
|
|
"providers:\n my-gateway:\n api: https://llm.internal.example.com/v1\n key_env: GW_KEY\n"
|
|
" unrelated:\n api: https://other.example.com/v1\n"
|
|
)
|
|
|
|
profile_dir = create_profile("coder", no_alias=True)
|
|
|
|
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text())
|
|
assert cfg["model"] == {"provider": "my-gateway", "default": "my-finetune"}
|
|
assert cfg["providers"] == {"my-gateway": {"api": "https://llm.internal.example.com/v1", "key_env": "GW_KEY"}}
|
|
|
|
def test_fresh_profile_model_is_copied_not_linked(self, profile_env):
|
|
"""Profiles stay independent islands.
|
|
|
|
The model block is copied at creation, so later edits to the source
|
|
profile never reach one already created from it.
|
|
"""
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text(
|
|
"model:\n provider: nous\n default: some/model\n"
|
|
)
|
|
profile_dir = create_profile("coder", no_alias=True)
|
|
|
|
(default_home / "config.yaml").write_text(
|
|
"model:\n provider: other\n default: changed/model\n"
|
|
)
|
|
|
|
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text())
|
|
assert cfg["model"]["provider"] == "nous"
|
|
assert cfg["model"]["default"] == "some/model"
|
|
|
|
|
|
|
|
|
|
def test_clone_config_copies_files(self, profile_env):
|
|
tmp_path = profile_env
|
|
default_home = tmp_path / ".hermes"
|
|
# Create source config files in default profile
|
|
(default_home / "config.yaml").write_text("model: test")
|
|
(default_home / ".env").write_text("KEY=val")
|
|
(default_home / "SOUL.md").write_text("Be helpful.")
|
|
|
|
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
|
|
|
|
cloned_config = yaml.safe_load((profile_dir / "config.yaml").read_text())
|
|
assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
|
assert cloned_config["model"] == "test"
|
|
assert (profile_dir / ".env").read_text().strip() == "KEY=val"
|
|
assert (profile_dir / "SOUL.md").read_text() == "Be helpful."
|
|
|
|
def test_clone_config_copies_only_the_active_memory_providers_config(self, profile_env):
|
|
"""#120115: --clone carried ``memory.provider: hindsight`` but not hindsight's own config,
|
|
so the clone booted with memory silently unavailable. Only the ACTIVE provider's
|
|
``<provider>/`` dir / ``<provider>.json`` travels; another provider's leftovers stay behind."""
|
|
tmp_path = profile_env
|
|
default_home = tmp_path / ".hermes"
|
|
(default_home / "config.yaml").write_text("memory:\n provider: hindsight\n")
|
|
(default_home / "hindsight").mkdir()
|
|
payload = '{"mode": "local_embedded", "bank_id": "hermes", "apiKey": "hs-secret"}'
|
|
(default_home / "hindsight" / "config.json").write_text(payload)
|
|
(default_home / "mem0.json").write_text('{"agent_id": "hermes"}')
|
|
|
|
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
|
|
|
|
cloned = profile_dir / "hindsight" / "config.json"
|
|
assert cloned.read_text() == payload
|
|
if os.name != "nt":
|
|
assert stat.S_IMODE(cloned.stat().st_mode) == 0o600
|
|
assert not (profile_dir / "mem0.json").exists()
|
|
|
|
@pytest.mark.parametrize("provider", ["../outside", "a/b", "..", "hind sight"])
|
|
def test_clone_config_ignores_unsafe_memory_provider_names(self, profile_env, provider):
|
|
"""A hand-edited ``memory.provider`` must never aim the copy outside the source profile."""
|
|
tmp_path = profile_env
|
|
default_home = tmp_path / ".hermes"
|
|
(default_home / "config.yaml").write_text(f"memory:\n provider: {provider!r}\n")
|
|
(tmp_path / "outside").mkdir()
|
|
(tmp_path / "outside" / "config.json").write_text("{}")
|
|
(default_home / "a").mkdir()
|
|
(default_home / "a" / "b").mkdir()
|
|
(default_home / "a" / "b" / "config.json").write_text("{}")
|
|
|
|
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
|
|
|
|
assert not (profile_dir / "a").exists()
|
|
assert not (profile_dir.parent / "outside").exists()
|
|
assert not (profile_dir / "hind sight").exists()
|
|
|
|
def test_clone_sync_imports_carries_manifest_but_never_links_profiles(self, profile_env):
|
|
"""--sync-imports copies import-sync.json (a pointer at EXTERNAL agent trees) and nothing
|
|
else changes: the clone still gets its own config/skills copies, never a live link."""
|
|
from hermes_cli.agent_import_sync import SYNC_MANIFEST_NAME, load_sync_manifest
|
|
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text("model: test")
|
|
manifest = {"version": 1, "agents": {"claude-code": {
|
|
"source": str(profile_env / ".claude"), "digest": "d", "overwrite": False,
|
|
"last_import": 1, "imported_skills": ["s1"]}}}
|
|
(default_home / SYNC_MANIFEST_NAME).write_text(json.dumps(manifest))
|
|
|
|
plain = create_profile("plain", clone_config=True, no_alias=True)
|
|
assert not (plain / SYNC_MANIFEST_NAME).exists()
|
|
|
|
synced = create_profile("synced", clone_config=True, sync_imports=True, no_alias=True)
|
|
assert load_sync_manifest(synced)["agents"] == manifest["agents"]
|
|
# Editing the source afterwards does not reach the clone: still an independent island.
|
|
(default_home / "config.yaml").write_text("model: changed")
|
|
assert yaml.safe_load((synced / "config.yaml").read_text())["model"] == "test"
|
|
|
|
@staticmethod
|
|
def _home_with_linked_skill(profile_env):
|
|
"""Source home: ``skills/foo`` links into an ``external_dirs`` root, ``skills/local`` is physical."""
|
|
default_home = profile_env / ".hermes"
|
|
external = profile_env / "agents-skills"
|
|
(external / "foo").mkdir(parents=True)
|
|
(external / "foo" / "SKILL.md").write_text("# external foo\n", encoding="utf-8")
|
|
(default_home / "skills" / "local").mkdir(parents=True)
|
|
(default_home / "skills" / "local" / "SKILL.md").write_text("# local\n", encoding="utf-8")
|
|
(default_home / "config.yaml").write_text(f"model: test\nskills:\n external_dirs:\n - {external}\n")
|
|
return default_home, external
|
|
|
|
@pytest.mark.parametrize("clone_kwargs", [{"clone_config": True}, {"clone_all": True}])
|
|
def test_clone_recreates_skill_junctions_and_skips_dangling_ones(self, profile_env, monkeypatch, clone_kwargs):
|
|
"""A junctioned skill stays a link (one candidate with its external original), a dangling
|
|
junction is skipped without failing the clone. The reparse-point predicate and CreateJunction
|
|
are Windows-only; simulate both so the copy/re-create contract runs on every host."""
|
|
default_home, external = self._home_with_linked_skill(profile_env)
|
|
# copytree sees plain directories (what a junction looks like to os.stat on Windows).
|
|
(default_home / "skills" / "foo").mkdir()
|
|
(default_home / "skills" / "foo" / "SKILL.md").write_text("# a physical copy would come from here\n")
|
|
(default_home / "skills" / "gone").mkdir()
|
|
targets = {str(default_home / "skills" / "foo"): str(external / "foo"),
|
|
str(default_home / "skills" / "gone"): str(profile_env / "nowhere")}
|
|
monkeypatch.setattr(profiles, "_junction_target", lambda path: targets.get(path), raising=False)
|
|
|
|
def _create_junction(target, dst):
|
|
if not os.path.isdir(target):
|
|
raise OSError("target missing") # what _winapi.CreateJunction does for a dangling junction
|
|
os.symlink(target, dst, target_is_directory=True)
|
|
monkeypatch.setitem(sys.modules, "_winapi", types.SimpleNamespace(CreateJunction=_create_junction))
|
|
|
|
clone = create_profile("clone", no_alias=True, **clone_kwargs)
|
|
foo = clone / "skills" / "foo"
|
|
assert foo.is_symlink() and foo.resolve() == (external / "foo").resolve()
|
|
assert (foo / "SKILL.md").read_text(encoding="utf-8") == "# external foo\n"
|
|
assert (clone / "skills" / "local" / "SKILL.md").is_file()
|
|
assert not (clone / "skills" / "gone").exists()
|
|
from tools.skills_tool import _collect_skill_candidates
|
|
assert len(_collect_skill_candidates("foo", None, [clone / "skills", external])) == 1
|
|
|
|
@pytest.mark.windows_only
|
|
def test_clone_keeps_real_ntfs_junction(self, profile_env):
|
|
import _winapi
|
|
default_home, external = self._home_with_linked_skill(profile_env)
|
|
_winapi.CreateJunction(str(external / "foo"), str(default_home / "skills" / "foo"))
|
|
|
|
clone = create_profile("clone", clone_config=True, no_alias=True)
|
|
foo = clone / "skills" / "foo"
|
|
assert os.lstat(foo).st_reparse_tag == profiles.stat.IO_REPARSE_TAG_MOUNT_POINT
|
|
assert foo.resolve() == (external / "foo").resolve()
|
|
from tools.skills_tool import _collect_skill_candidates
|
|
assert len(_collect_skill_candidates("foo", None, [clone / "skills", external])) == 1
|
|
|
|
def test_sync_imports_requires_a_clone_source(self, profile_env):
|
|
with pytest.raises(ValueError, match="--sync-imports requires"):
|
|
create_profile("lonely", sync_imports=True, no_alias=True)
|
|
|
|
def test_clone_all_does_not_copy_cron_jobs(self, profile_env):
|
|
# Cron jobs are scheduled work bound to the source profile + origin channel; a clone
|
|
# that inherits jobs.json fires every job twice (two gateways, same job ids).
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text("model: test")
|
|
(default_home / "cron").mkdir()
|
|
(default_home / "cron" / "jobs.json").write_text(json.dumps({"jobs": [{"id": "abc123def456"}]}))
|
|
(default_home / "cron" / "output").mkdir()
|
|
|
|
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
|
|
|
|
assert (profile_dir / "cron").is_dir()
|
|
assert not any((profile_dir / "cron").iterdir())
|
|
assert yaml.safe_load((profile_dir / "config.yaml").read_text())["model"] == "test"
|
|
|
|
def test_clone_all_does_not_inherit_the_source_screen_or_browser_process_artifacts(self, profile_env):
|
|
"""A clone keeps browser data, never the source's screen or Chromium runtime files."""
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text("model: test")
|
|
bd = default_home / "bot-desktop"
|
|
browser_profile = bd / "browser-profile"
|
|
(browser_profile / "Default").mkdir(parents=True)
|
|
(browser_profile / "Default" / "Cookies").write_text("jar")
|
|
(bd / "launcher.pid").write_text("4242 1.5")
|
|
(bd / "env").write_text("DISPLAY=:21\nXAUTHORITY=/x\n")
|
|
(bd / "lease.json").write_text(json.dumps({"holder": "human", "viewer_id": "v", "epoch": 3}))
|
|
process_markers = ("DevToolsActivePort", "SingletonLock", "SingletonCookie", "SingletonSocket")
|
|
for process_marker in process_markers:
|
|
(browser_profile / process_marker).write_text("source-process")
|
|
|
|
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
|
|
cloned_browser = profile_dir / "bot-desktop" / "browser-profile"
|
|
|
|
for runtime_file in ("launcher.pid", "env", "lease.json"):
|
|
assert not (profile_dir / "bot-desktop" / runtime_file).exists(), runtime_file
|
|
for process_marker in process_markers:
|
|
assert not (cloned_browser / process_marker).exists(), process_marker
|
|
assert (cloned_browser / "Default" / "Cookies").read_text() == "jar"
|
|
|
|
@pytest.mark.linux_only
|
|
def test_clone_all_does_not_attach_to_the_source_profiles_live_browser(self, profile_env):
|
|
"""Copied Chromium markers must not route the clone through the source profile's CDP port."""
|
|
from tools.bot_desktop.browser import running_instance_cdp_port
|
|
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text("model: test")
|
|
browser_profile = default_home / "bot-desktop" / "browser-profile"
|
|
browser_profile.mkdir(parents=True)
|
|
|
|
with socket.socket() as listener:
|
|
listener.bind(("127.0.0.1", 0))
|
|
listener.listen()
|
|
port = listener.getsockname()[1]
|
|
(browser_profile / "DevToolsActivePort").write_text(f"{port}\n/devtools/browser/source\n")
|
|
(browser_profile / "SingletonLock").symlink_to(f"host-{os.getpid()}")
|
|
assert running_instance_cdp_port(str(browser_profile)) == port
|
|
|
|
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
|
|
cloned_browser = profile_dir / "bot-desktop" / "browser-profile"
|
|
|
|
assert running_instance_cdp_port(str(cloned_browser)) is None
|
|
assert running_instance_cdp_port(str(browser_profile)) == port
|
|
|
|
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="special files need a POSIX filesystem")
|
|
def test_clone_all_skips_special_files(self, profile_env):
|
|
# A live source profile holds special files copytree cannot copy (e.g. a suffixless
|
|
# agent-browser control socket); one of them must not abort the whole clone.
|
|
default_home = profile_env / ".hermes"
|
|
(default_home / "config.yaml").write_text("model: test")
|
|
browser_dir = default_home / "home" / ".agent-browser"
|
|
browser_dir.mkdir(parents=True)
|
|
(browser_dir / "state.json").write_text("{}")
|
|
os.mkfifo(browser_dir / "control")
|
|
|
|
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
|
|
|
|
assert (profile_dir / "home" / ".agent-browser" / "state.json").is_file()
|
|
assert not (profile_dir / "home" / ".agent-browser" / "control").exists()
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestNoSkillsOptOut
|
|
# ===================================================================
|
|
|
|
class TestNoSkillsOptOut:
|
|
"""Tests for `hermes profile create --no-skills` and the opt-out marker."""
|
|
|
|
def test_no_skills_writes_marker_and_skips_seeding(self, profile_env):
|
|
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
|
|
|
|
# Marker file is present
|
|
marker = profile_dir / NO_BUNDLED_SKILLS_MARKER
|
|
assert marker.is_file(), "expected .no-bundled-skills marker in profile root"
|
|
assert "--no-skills" in marker.read_text()
|
|
|
|
# skills/ dir exists (profile bootstrapping still creates the dir) but
|
|
# contains nothing yet because create_profile itself doesn't seed.
|
|
assert (profile_dir / "skills").is_dir()
|
|
assert list((profile_dir / "skills").iterdir()) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestBackfillProfileEnvs
|
|
# ===================================================================
|
|
|
|
class TestBackfillProfileEnvs:
|
|
"""Tests for backfill_profile_envs() — the `hermes update` pass that
|
|
gives pre-#44792 profiles (created before .env seeding) their own
|
|
.env, copied from the default install so credentials don't break."""
|
|
|
|
def test_copies_default_env_into_envless_profiles(self, profile_env):
|
|
import stat
|
|
tmp_path = profile_env
|
|
(tmp_path / ".hermes" / ".env").write_text("OPENROUTER_API_KEY=root-key\n")
|
|
p1 = create_profile("old1", no_alias=True)
|
|
p2 = create_profile("old2", no_alias=True)
|
|
# Simulate pre-#44792 profiles: no .env
|
|
(p1 / ".env").unlink()
|
|
(p2 / ".env").unlink()
|
|
|
|
backfilled = backfill_profile_envs(quiet=True)
|
|
|
|
assert sorted(backfilled) == ["old1", "old2"]
|
|
for p in (p1, p2):
|
|
assert (p / ".env").read_text() == "OPENROUTER_API_KEY=root-key\n"
|
|
assert stat.S_IMODE((p / ".env").stat().st_mode) == 0o600
|
|
|
|
|
|
def test_placeholder_when_default_has_no_env(self, profile_env):
|
|
p = create_profile("noroot", no_alias=True)
|
|
(p / ".env").unlink()
|
|
|
|
backfilled = backfill_profile_envs(quiet=True)
|
|
|
|
assert backfilled == ["noroot"]
|
|
content = (p / ".env").read_text(encoding="utf-8")
|
|
assert all(
|
|
line.startswith("#") or not line.strip()
|
|
for line in content.splitlines()
|
|
)
|
|
|
|
def test_no_profiles_root_is_noop(self, profile_env):
|
|
assert backfill_profile_envs(quiet=True) == []
|
|
|
|
|
|
# ===================================================================
|
|
# TestDeleteProfile
|
|
# ===================================================================
|
|
|
|
class TestDeleteProfile:
|
|
"""Tests for delete_profile()."""
|
|
|
|
|
|
def test_rmtree_failure_raises(self, profile_env):
|
|
profile_dir = create_profile("coder", no_alias=True)
|
|
set_active_profile("coder")
|
|
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
|
|
patch("hermes_cli.profiles.time.sleep"), \
|
|
patch("hermes_cli.profiles.shutil.rmtree", side_effect=PermissionError("locked")):
|
|
with pytest.raises(RuntimeError, match="Could not remove profile directory"):
|
|
delete_profile("coder", yes=True)
|
|
|
|
assert profile_dir.is_dir()
|
|
assert get_active_profile() == "default"
|
|
|
|
def test_delete_purges_profile_keyed_identity(self, profile_env):
|
|
"""A deleted profile must not keep routing/heartbeat/delivery identity (#111926, delete side).
|
|
|
|
The name is baked into ``agent:<name>:*`` routing keys, ``gateway_heartbeats.profile`` and
|
|
``delivery_obligations``. Left behind, a later event on a chat keyed to the deleted name
|
|
enters the routing index, resolves a profile whose directory is gone, and logs
|
|
``Profile '<name>' does not exist`` on every subsequent event.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
import time
|
|
|
|
tmp_path = profile_env
|
|
create_profile("gone", no_alias=True)
|
|
create_profile("keepme", no_alias=True)
|
|
scope = str(tmp_path / ".hermes" / "sessions")
|
|
db = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
db.save_gateway_routing_entry(
|
|
"agent:gone:feishu:dm:chatA",
|
|
json.dumps({"session_key": "agent:gone:feishu:dm:chatA",
|
|
"origin": {"platform": "feishu", "chat_id": "chatA",
|
|
"profile": "gone"}}),
|
|
scope=scope)
|
|
db.save_gateway_routing_entry(
|
|
"agent:keepme:feishu:dm:chatB",
|
|
json.dumps({"session_key": "agent:keepme:feishu:dm:chatB",
|
|
"origin": {"platform": "feishu", "chat_id": "chatB",
|
|
"profile": "keepme"}}),
|
|
scope=scope)
|
|
db.register_backend_heartbeat(
|
|
backend_id="be-gone", pid=1, started_at=time.time(), profile="gone", host="h")
|
|
db.register_backend_heartbeat(
|
|
backend_id="be-keep", pid=2, started_at=time.time(), profile="keepme", host="h")
|
|
db.close()
|
|
|
|
# No live multiplexer: nothing else owns the store, so this process purges the durable rows.
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=False):
|
|
delete_profile("gone", yes=True)
|
|
|
|
check = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
try:
|
|
assert set(check.load_gateway_routing_entries(scope=scope)) == {
|
|
"agent:keepme:feishu:dm:chatB"}
|
|
assert check._read_one(
|
|
"SELECT COUNT(*) AS n FROM gateway_heartbeats WHERE profile = ?",
|
|
("gone",))["n"] == 0
|
|
assert check._read_one(
|
|
"SELECT COUNT(*) AS n FROM gateway_heartbeats WHERE profile = ?",
|
|
("keepme",))["n"] == 1
|
|
finally:
|
|
check.close()
|
|
|
|
def test_delete_reports_pending_settlement_for_a_live_multiplexer(self, profile_env, capsys):
|
|
"""With a live multiplexer the owner process purges, so the CLI must not race it (#111926).
|
|
|
|
That process holds the routing index in memory and writes it back, so a CLI-side DELETE
|
|
would be undone by its next save. When it cannot be reached the delete is NOT a clean
|
|
success: the identity settlement is reported as pending, with the retry named.
|
|
"""
|
|
from hermes_state import SessionDB
|
|
from hermes_cli.profiles import ProfileIdentitySettlementPending
|
|
|
|
tmp_path = profile_env
|
|
create_profile("gone", no_alias=True)
|
|
scope = str(tmp_path / ".hermes" / "sessions")
|
|
db = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
db.save_gateway_routing_entry(
|
|
"agent:gone:feishu:dm:chatA",
|
|
json.dumps({"session_key": "agent:gone:feishu:dm:chatA"}),
|
|
scope=scope)
|
|
db.close()
|
|
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=True):
|
|
with pytest.raises(ProfileIdentitySettlementPending,
|
|
match="identity settlement is still pending") as ei:
|
|
delete_profile("gone", yes=True)
|
|
|
|
# Typed partial success: the filesystem delete completed, the identity did not, and the
|
|
# payload carries what a surfacing caller needs to report it and retry.
|
|
assert ei.value.profile == "gone"
|
|
assert ei.value.retry_command == "hermes profile purge-identity gone"
|
|
assert not ei.value.path.exists()
|
|
assert isinstance(ei.value, RuntimeError) # the CLI handler catches RuntimeError
|
|
|
|
assert "hermes profile purge-identity gone" in capsys.readouterr().err
|
|
check = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
try:
|
|
# The CLI left the identity alone rather than racing the live owner.
|
|
assert set(check.load_gateway_routing_entries(scope=scope)) == {
|
|
"agent:gone:feishu:dm:chatA"}
|
|
finally:
|
|
check.close()
|
|
|
|
def test_backend_scan_only_matches_this_profile(self, profile_env, monkeypatch):
|
|
"""The backend PID scan binds by --profile selector and skips self."""
|
|
create_profile("coder", no_alias=True)
|
|
profile_dir = get_profile_dir("coder")
|
|
|
|
class FakeProc:
|
|
def __init__(self, pid, cmdline, username="me"):
|
|
self.pid = pid
|
|
self.info = {"pid": pid, "name": "python", "username": username, "cmdline": cmdline}
|
|
|
|
def parent(self):
|
|
return None
|
|
|
|
def username(self):
|
|
return "me"
|
|
|
|
def environ(self):
|
|
return {}
|
|
|
|
self_pid = os.getpid()
|
|
procs = [
|
|
# Backend bound to coder → matched.
|
|
FakeProc(101, ["python", "-m", "hermes_cli.main", "--profile", "coder", "serve"]),
|
|
# Interactive chat for coder → NOT a backend subcommand, skipped.
|
|
FakeProc(102, ["python", "-m", "hermes_cli.main", "--profile", "coder", "chat"]),
|
|
# Backend for a different profile → skipped.
|
|
FakeProc(103, ["python", "-m", "hermes_cli.main", "--profile", "other", "serve"]),
|
|
# This very process → skipped even if it matched.
|
|
FakeProc(self_pid, ["python", "-m", "hermes_cli.main", "--profile", "coder", "serve"]),
|
|
]
|
|
|
|
fake_psutil = types.SimpleNamespace(
|
|
process_iter=lambda attrs=None: iter(procs),
|
|
Process=lambda pid=None: FakeProc(self_pid, []),
|
|
NoSuchProcess=Exception,
|
|
AccessDenied=Exception,
|
|
ZombieProcess=Exception,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
|
|
pids = profiles._profile_bound_backend_pids("coder", profile_dir)
|
|
assert pids == [101]
|
|
|
|
def test_backend_scan_matches_shebang_exec_of_hermes_shim(self, profile_env, monkeypatch):
|
|
"""A `hermes` console-script shim spawned directly (e.g. Electron's
|
|
findOnPath('hermes') resolution) reports argv[0] as the interpreter
|
|
(python3) and argv[1] as the shim's path -- not "hermes" -- because
|
|
the OS execs the shebang. The scanner must still recognize it so
|
|
profile delete doesn't leave a zombie Desktop-spawned backend behind
|
|
(issue: deleting a Desktop profile kept reappearing after relaunch).
|
|
"""
|
|
create_profile("coder", no_alias=True)
|
|
profile_dir = get_profile_dir("coder")
|
|
|
|
class FakeProc:
|
|
def __init__(self, pid, cmdline, username="me"):
|
|
self.pid = pid
|
|
self.info = {"pid": pid, "name": "python3", "username": username, "cmdline": cmdline}
|
|
|
|
def parent(self):
|
|
return None
|
|
|
|
def username(self):
|
|
return "me"
|
|
|
|
def environ(self):
|
|
return {}
|
|
|
|
self_pid = os.getpid()
|
|
procs = [
|
|
# Shebang-exec'd shim bound to coder → matched despite argv[0]
|
|
# being the python interpreter, not "hermes".
|
|
FakeProc(201, ["/usr/bin/python3", "/Users/x/.local/bin/hermes", "--profile", "coder", "serve",
|
|
"--host", "127.0.0.1", "--port", "0"]),
|
|
# Same shape but a different profile → skipped.
|
|
FakeProc(202, ["/usr/bin/python3", "/Users/x/.local/bin/hermes", "--profile", "other", "serve"]),
|
|
# Non-hermes script run by python3 → skipped.
|
|
FakeProc(203, ["/usr/bin/python3", "/Users/x/some_script.py", "--profile", "coder", "serve"]),
|
|
]
|
|
|
|
fake_psutil = types.SimpleNamespace(
|
|
process_iter=lambda attrs=None: iter(procs),
|
|
Process=lambda pid=None: FakeProc(self_pid, []),
|
|
NoSuchProcess=Exception,
|
|
AccessDenied=Exception,
|
|
ZombieProcess=Exception,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
|
|
pids = profiles._profile_bound_backend_pids("coder", profile_dir)
|
|
assert pids == [201]
|
|
|
|
def test_backend_scan_rejects_unrelated_hermes_prefixed_script(self, profile_env, monkeypatch):
|
|
"""A user's own script that happens to start with "hermes" (e.g.
|
|
hermes-notes.py, hermes-unrelated-tool) must NOT be misidentified as
|
|
the console-script shim just because argv[0] is a python interpreter
|
|
and argv[1]'s basename starts with "hermes" -- only the actual known
|
|
console-script entry points (hermes, hermes-agent, hermes-acp) count.
|
|
"""
|
|
create_profile("coder", no_alias=True)
|
|
profile_dir = get_profile_dir("coder")
|
|
|
|
class FakeProc:
|
|
def __init__(self, pid, cmdline, username="me"):
|
|
self.pid = pid
|
|
self.info = {"pid": pid, "name": "python3", "username": username, "cmdline": cmdline}
|
|
|
|
def parent(self):
|
|
return None
|
|
|
|
def username(self):
|
|
return "me"
|
|
|
|
def environ(self):
|
|
return {}
|
|
|
|
self_pid = os.getpid()
|
|
procs = [
|
|
# Looks like the shim by prefix alone, but is the user's own
|
|
# unrelated tool -- must be rejected, not killed by profile delete.
|
|
FakeProc(301, ["/usr/bin/python3", "/Users/x/scripts/hermes-notes.py",
|
|
"--profile", "coder", "serve"]),
|
|
FakeProc(302, ["/usr/bin/python3", "/Users/x/scripts/hermes-unrelated-tool",
|
|
"--profile", "coder", "serve"]),
|
|
]
|
|
|
|
fake_psutil = types.SimpleNamespace(
|
|
process_iter=lambda attrs=None: iter(procs),
|
|
Process=lambda pid=None: FakeProc(self_pid, []),
|
|
NoSuchProcess=Exception,
|
|
AccessDenied=Exception,
|
|
ZombieProcess=Exception,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
|
|
pids = profiles._profile_bound_backend_pids("coder", profile_dir)
|
|
assert pids == []
|
|
|
|
def test_backend_scan_matches_all_known_console_script_shims(self, profile_env, monkeypatch):
|
|
"""The other two real console-script entry points (hermes-agent,
|
|
hermes-acp -- see pyproject.toml [project.scripts]) must also be
|
|
recognized via the shebang-exec path, not just the primary "hermes"
|
|
shim.
|
|
"""
|
|
create_profile("coder", no_alias=True)
|
|
profile_dir = get_profile_dir("coder")
|
|
|
|
class FakeProc:
|
|
def __init__(self, pid, cmdline, username="me"):
|
|
self.pid = pid
|
|
self.info = {"pid": pid, "name": "python3", "username": username, "cmdline": cmdline}
|
|
|
|
def parent(self):
|
|
return None
|
|
|
|
def username(self):
|
|
return "me"
|
|
|
|
def environ(self):
|
|
return {}
|
|
|
|
self_pid = os.getpid()
|
|
procs = [
|
|
FakeProc(401, ["/usr/bin/python3", "/Users/x/.local/bin/hermes-agent",
|
|
"--profile", "coder", "serve"]),
|
|
FakeProc(402, ["/usr/bin/python3", "/Users/x/.local/bin/hermes-acp",
|
|
"--profile", "coder", "serve"]),
|
|
]
|
|
|
|
fake_psutil = types.SimpleNamespace(
|
|
process_iter=lambda attrs=None: iter(procs),
|
|
Process=lambda pid=None: FakeProc(self_pid, []),
|
|
NoSuchProcess=Exception,
|
|
AccessDenied=Exception,
|
|
ZombieProcess=Exception,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
|
|
pids = profiles._profile_bound_backend_pids("coder", profile_dir)
|
|
assert set(pids) == {401, 402}
|
|
|
|
|
|
# ===================================================================
|
|
# TestListProfiles
|
|
# ===================================================================
|
|
|
|
class TestListProfiles:
|
|
"""Tests for list_profiles()."""
|
|
|
|
def test_returns_default_when_no_named_profiles(self, profile_env):
|
|
profiles = list_profiles()
|
|
names = [p.name for p in profiles]
|
|
assert "default" in names
|
|
|
|
def test_includes_named_profiles(self, profile_env):
|
|
create_profile("alpha", no_alias=True)
|
|
create_profile("beta", no_alias=True)
|
|
profiles = list_profiles()
|
|
names = [p.name for p in profiles]
|
|
assert "alpha" in names
|
|
assert "beta" in names
|
|
|
|
def test_lazy_skill_count_never_walks_in_the_polled_request(self, profile_env, monkeypatch):
|
|
"""Polled surfaces (``profiles.list`` RPC, ``GET /api/profiles``) must render
|
|
``skill_count`` without any skill-tree walk on the request thread; the count arrives
|
|
from one background refresh per profile per recheck window (#114041). Control: the
|
|
synchronous ``list_profiles()`` still walks and reports the fresh number."""
|
|
import threading
|
|
import tui_gateway.server as srv
|
|
|
|
skills = profile_env / ".hermes" / "skills" / "cat"
|
|
for i in range(3):
|
|
(skills / f"s{i}").mkdir(parents=True)
|
|
(skills / f"s{i}" / "SKILL.md").write_text("# s\n", encoding="utf-8")
|
|
profiles._SKILL_COUNT_CACHE.clear()
|
|
profiles._SKILL_COUNT_NEXT_CHECK.clear()
|
|
|
|
walks: list[str] = []
|
|
real_walk = profiles._walk_skill_count
|
|
|
|
def spy(skills_dir):
|
|
walks.append(threading.current_thread().name)
|
|
return real_walk(skills_dir)
|
|
|
|
monkeypatch.setattr(profiles, "_walk_skill_count", spy)
|
|
|
|
def _rpc():
|
|
return srv._methods["profiles.list"](1, {"include_sessions": False})["result"]["profiles"]
|
|
|
|
first = _rpc()
|
|
assert walks == [] or set(walks) == {"hermes-skill-count"}
|
|
assert first[0]["skill_count"] in (0, 3) # 0 until the refresh lands, never a stall
|
|
for t in threading.enumerate():
|
|
if t.name == "hermes-skill-count":
|
|
t.join(timeout=10)
|
|
assert walks == ["hermes-skill-count"]
|
|
assert _rpc()[0]["skill_count"] == 3
|
|
assert list_profiles(lazy_skill_count=True)[0].skill_count == 3
|
|
assert walks == ["hermes-skill-count"] # a second poll inside the window schedules nothing
|
|
|
|
# GET /api/profiles (the router's own ``lazy_skill_count=True`` call) and the per-keystroke
|
|
# ``@<profile>`` completion must be just as walk-free: same spy, still one background walk.
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from hermes_cli.web_routers import profiles as profiles_router
|
|
from tui_gateway import methods_complete
|
|
app = FastAPI()
|
|
app.include_router(profiles_router.router)
|
|
# Cold cache: a synchronous list_profiles() in either caller would walk on the request thread.
|
|
profiles._SKILL_COUNT_CACHE.clear()
|
|
resp = TestClient(app).get("/api/profiles")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["profiles"][0]["name"] == "default"
|
|
assert walks == ["hermes-skill-count"]
|
|
assert any(i["text"] == "@default" for i in methods_complete._profile_mention_items("def"))
|
|
assert walks == ["hermes-skill-count"]
|
|
|
|
# Control: the detail/CLI path counts synchronously on the caller's thread.
|
|
profiles._SKILL_COUNT_CACHE.clear()
|
|
assert list_profiles()[0].skill_count == 3
|
|
assert walks[-1] == threading.current_thread().name
|
|
|
|
def test_skill_count_survives_subtree_vanishing_mid_walk(self, profile_env, monkeypatch):
|
|
"""A skill removed while the tree is being counted (concurrent install/update) must
|
|
degrade the count, not abort profile enumeration with ``FileNotFoundError``."""
|
|
skills = profile_env / ".hermes" / "skills" / "cat"
|
|
for i in range(4):
|
|
(skills / f"s{i}" / "references").mkdir(parents=True)
|
|
(skills / f"s{i}" / "SKILL.md").write_text("# s\n", encoding="utf-8")
|
|
profiles._SKILL_COUNT_CACHE.clear()
|
|
real_scandir = os.scandir
|
|
|
|
class _Listing:
|
|
"""A pre-read scandir result (context manager + iterator, like the real one)."""
|
|
def __init__(self, entries):
|
|
self._it = iter(entries)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def __next__(self):
|
|
return next(self._it)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def vanishing_scandir(path=".", *args, **kwargs):
|
|
with real_scandir(path, *args, **kwargs) as listing:
|
|
entries = list(listing)
|
|
if not isinstance(path, int) and os.fspath(path) == str(skills):
|
|
# Listed, then gone before the walk descends into it.
|
|
shutil.rmtree(skills / "s3", ignore_errors=True)
|
|
return _Listing(entries)
|
|
|
|
monkeypatch.setattr(os, "scandir", vanishing_scandir)
|
|
assert profiles._count_skills(profile_env / ".hermes") == 3
|
|
assert [p.name for p in list_profiles()] == ["default"]
|
|
|
|
|
|
# ===================================================================
|
|
# TestActiveProfile
|
|
# ===================================================================
|
|
|
|
class TestActiveProfile:
|
|
"""Tests for set_active_profile() / get_active_profile()."""
|
|
|
|
|
|
def test_set_to_default_removes_file(self, profile_env):
|
|
tmp_path = profile_env
|
|
create_profile("coder", no_alias=True)
|
|
set_active_profile("coder")
|
|
active_path = tmp_path / ".hermes" / "active_profile"
|
|
assert active_path.exists()
|
|
|
|
set_active_profile("default")
|
|
assert not active_path.exists()
|
|
|
|
|
|
# ===================================================================
|
|
# TestGetActiveProfileName
|
|
# ===================================================================
|
|
|
|
class TestGetActiveProfileName:
|
|
"""Tests for get_active_profile_name()."""
|
|
|
|
|
|
def test_profile_path_returns_profile_name(self, profile_env, monkeypatch):
|
|
tmp_path = profile_env
|
|
create_profile("coder", no_alias=True)
|
|
profile_dir = tmp_path / ".hermes" / "profiles" / "coder"
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
|
|
assert get_active_profile_name() == "coder"
|
|
|
|
def test_custom_path_returns_default(self, profile_env, monkeypatch):
|
|
"""A custom HERMES_HOME (Docker, etc.) IS the default root."""
|
|
tmp_path = profile_env
|
|
custom = tmp_path / "some" / "other" / "path"
|
|
custom.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(custom))
|
|
# With Docker-aware roots, a custom HERMES_HOME is the default —
|
|
# not "custom". The user is on the default profile of their
|
|
# custom deployment.
|
|
assert get_active_profile_name() == "default"
|
|
|
|
|
|
# ===================================================================
|
|
# TestResolveProfileEnv
|
|
# ===================================================================
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestAliasCollision
|
|
# ===================================================================
|
|
|
|
class TestAliasCollision:
|
|
"""Tests for check_alias_collision()."""
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_windows_checks_bat_extension(self, profile_env):
|
|
wrapper_dir = profile_env / ".local" / "bin"
|
|
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
|
bat_path = wrapper_dir / "mybot.bat"
|
|
bat_path.write_text("@echo off\r\nhermes -p mybot %*\r\n")
|
|
with patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0, stdout=str(bat_path),
|
|
)
|
|
result = check_alias_collision("mybot")
|
|
assert result is None # our own wrapper, safe to overwrite
|
|
|
|
def test_traversal_alias_rejected_before_path_lookup(self, profile_env):
|
|
"""A path-traversal alias is rejected without ever shelling out to which/where."""
|
|
with patch("subprocess.run") as mock_run:
|
|
result = check_alias_collision("../../.bashrc")
|
|
assert result is not None
|
|
assert "invalid alias name" in result.lower()
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
# ===================================================================
|
|
# TestWrapperScript
|
|
# ===================================================================
|
|
|
|
class TestWrapperScript:
|
|
"""Tests for create_wrapper_script() and remove_wrapper_script()."""
|
|
|
|
def test_creates_sh_on_posix(self, profile_env, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.profiles.shutil.which", lambda name: "/opt/hermes/bin/hermes")
|
|
from hermes_cli.profiles import create_wrapper_script
|
|
wrapper = create_wrapper_script("mybot")
|
|
assert wrapper is not None
|
|
assert wrapper.name == "mybot"
|
|
content = wrapper.read_text()
|
|
assert content.startswith("#!/bin/sh")
|
|
assert "exec /opt/hermes/bin/hermes -p mybot" in content
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_remove_finds_bat_on_windows(self, profile_env):
|
|
from hermes_cli.profiles import create_wrapper_script
|
|
wrapper = create_wrapper_script("mybot")
|
|
assert wrapper is not None
|
|
assert wrapper.exists()
|
|
removed = remove_wrapper_script("mybot")
|
|
assert removed is True
|
|
assert not wrapper.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestWrapperScriptSecurity — path-traversal hardening
|
|
# ===================================================================
|
|
|
|
class TestWrapperScriptSecurity:
|
|
"""A crafted alias name must not escape the wrapper directory."""
|
|
|
|
|
|
|
|
|
|
def test_create_wrapper_rejects_traversal(self, profile_env):
|
|
sentinel = profile_env / ".bashrc"
|
|
sentinel.write_text("keep", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="Invalid alias name"):
|
|
create_wrapper_script("../../.bashrc", target="coder")
|
|
# The traversal target was not touched.
|
|
assert sentinel.read_text(encoding="utf-8") == "keep"
|
|
|
|
def test_create_wrapper_rejects_absolute_path(self, profile_env, tmp_path):
|
|
target = tmp_path / "abs-wrapper"
|
|
with pytest.raises(ValueError, match="Invalid alias name"):
|
|
create_wrapper_script(str(target))
|
|
assert not target.exists()
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestFindAliasForProfile — display-side reverse lookup
|
|
# ===================================================================
|
|
|
|
class TestFindAliasForProfile:
|
|
"""Tests for find_alias_for_profile() and alias display in list/show."""
|
|
|
|
def test_profile_named_alias(self, profile_env):
|
|
from hermes_cli.profiles import create_wrapper_script, find_alias_for_profile
|
|
create_wrapper_script("steve")
|
|
assert find_alias_for_profile("steve") == "steve"
|
|
|
|
|
|
def test_ignores_unrelated_files(self, profile_env):
|
|
# ~/.local/bin commonly holds unrelated binaries; they must not match.
|
|
from hermes_cli.profiles import _get_wrapper_dir, find_alias_for_profile
|
|
wrapper_dir = _get_wrapper_dir()
|
|
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
|
(wrapper_dir / "pip").write_text("#!/bin/sh\nexec python -m pip \"$@\"\n")
|
|
assert find_alias_for_profile("steve") is None
|
|
|
|
|
|
def test_list_profiles_surfaces_custom_alias(self, profile_env):
|
|
from hermes_cli.profiles import (
|
|
create_profile,
|
|
create_wrapper_script,
|
|
list_profiles,
|
|
)
|
|
create_profile("steve", no_alias=True)
|
|
create_wrapper_script("qiaobusi", target="steve")
|
|
info = next(p for p in list_profiles() if p.name == "steve")
|
|
assert info.alias_name == "qiaobusi"
|
|
assert info.alias_path is not None
|
|
assert info.alias_path.name == "qiaobusi"
|
|
|
|
|
|
# ===================================================================
|
|
# TestRenameProfile
|
|
# ===================================================================
|
|
|
|
class TestRenameProfile:
|
|
"""Tests for rename_profile()."""
|
|
|
|
def test_renames_directory(self, profile_env):
|
|
tmp_path = profile_env
|
|
create_profile("oldname", no_alias=True)
|
|
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
|
|
assert old_dir.is_dir()
|
|
|
|
# Mock alias collision to avoid subprocess calls
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
|
new_dir = rename_profile("oldname", "newname")
|
|
|
|
assert not old_dir.is_dir()
|
|
assert new_dir.is_dir()
|
|
assert new_dir == tmp_path / ".hermes" / "profiles" / "newname"
|
|
|
|
def test_renames_root_honcho_host_without_changing_ai_peer(self, profile_env):
|
|
tmp_path = profile_env
|
|
create_profile("ssi_health", no_alias=True)
|
|
honcho_path = tmp_path / ".hermes" / "honcho.json"
|
|
honcho_path.write_text(json.dumps({
|
|
"hosts": {
|
|
"hermes.ssi_health": {
|
|
"recallMode": "hybrid",
|
|
"writeFrequency": "async",
|
|
"sessionStrategy": "per-session",
|
|
"saveMessages": True,
|
|
"peerName": "user-peer",
|
|
"aiPeer": "ssi_health",
|
|
"workspace": "hermes",
|
|
"enabled": True,
|
|
}
|
|
}
|
|
}))
|
|
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
|
rename_profile("ssi_health", "heimdall")
|
|
|
|
cfg = json.loads(honcho_path.read_text())
|
|
assert "hermes.ssi_health" not in cfg["hosts"]
|
|
assert cfg["hosts"]["hermes_heimdall"]["aiPeer"] == "ssi_health"
|
|
assert cfg["hosts"]["hermes_heimdall"]["peerName"] == "user-peer"
|
|
|
|
def test_multiplexed_rename_unroutes_old_then_hot_serves_new(self, profile_env):
|
|
"""Under a live multiplexer the old name is tombstoned + unrouted BEFORE the directory
|
|
moves and the new name is hot-served after, so a stale runtime mkdir of the old home is
|
|
refused instead of resurrecting a ghost served profile (#109267)."""
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
tmp_path = profile_env
|
|
create_profile("oldname", no_alias=True)
|
|
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
|
|
new_dir = tmp_path / ".hermes" / "profiles" / "newname"
|
|
|
|
calls = []
|
|
|
|
def _record_notify(name):
|
|
# Snapshot the world at each multiplexer signal to pin ordering.
|
|
calls.append((name, old_dir.exists(), new_dir.exists(), profiles.named_profile_is_deleted(old_dir)))
|
|
if name == "oldname" and old_dir.exists():
|
|
# A still-live component of the multiplexer writing into the old home mid-teardown.
|
|
with pytest.raises(FileNotFoundError):
|
|
mkdir_under_hermes_home(old_dir / "logs")
|
|
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
|
|
patch("hermes_cli.profiles._notify_multiplexer", side_effect=_record_notify):
|
|
rename_profile("oldname", "newname")
|
|
|
|
# (name, old_exists, new_exists, old_tombstoned): unroute first, hot-serve last.
|
|
assert calls[0] == ("oldname", True, False, True)
|
|
assert calls[-1] == ("newname", False, True, False)
|
|
assert not old_dir.exists() and new_dir.is_dir()
|
|
assert not profiles.named_profile_is_deleted(old_dir) # a future 'oldname' is not born deleted
|
|
|
|
def test_unmultiplexed_rename_does_not_signal_multiplexer(self, profile_env):
|
|
"""No live multiplexer → rename must neither tombstone nor ping (single-profile installs)."""
|
|
tmp_path = profile_env
|
|
create_profile("oldname", no_alias=True)
|
|
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
|
|
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=False), \
|
|
patch("hermes_cli.profiles._notify_multiplexer") as notify:
|
|
new_dir = rename_profile("oldname", "newname")
|
|
|
|
notify.assert_not_called()
|
|
assert not (tmp_path / ".hermes" / "profiles" / ".deleted").exists()
|
|
assert not old_dir.exists() and new_dir.is_dir()
|
|
|
|
def test_rename_migrates_session_identity_without_live_gateway(self, profile_env):
|
|
"""No live gateway → the CLI performs the durable rekey itself so a renamed profile's session
|
|
keys / profile_name / routing rows follow the new name (else inbound events on the old name's
|
|
chats resolve to a nonexistent profile and flood errors.log)."""
|
|
from hermes_state import SessionDB
|
|
tmp_path = profile_env
|
|
create_profile("oldname", no_alias=True)
|
|
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
|
|
# Seed a session owned by the old profile in the profile's own store + the root routing index.
|
|
pdb = SessionDB(old_dir / "state.db")
|
|
pdb.create_session(
|
|
"sess1", "feishu", session_key="agent:oldname:feishu:dm:chatA",
|
|
profile_name="oldname", chat_id="chatA", chat_type="dm")
|
|
pdb.close()
|
|
root_db = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
root_db.save_gateway_routing_entry(
|
|
"agent:oldname:feishu:dm:chatA",
|
|
json.dumps({"session_key": "agent:oldname:feishu:dm:chatA", "session_id": "sess1",
|
|
"origin": {"platform": "feishu", "chat_id": "chatA", "profile": "oldname"}}),
|
|
scope=str(tmp_path / ".hermes" / "sessions"))
|
|
root_db.close()
|
|
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=False):
|
|
rename_profile("oldname", "newname")
|
|
|
|
new_dir = tmp_path / ".hermes" / "profiles" / "newname"
|
|
moved_db = SessionDB(new_dir / "state.db")
|
|
row = moved_db._read_one(
|
|
"SELECT session_key, profile_name FROM sessions WHERE id = ?", ("sess1",))
|
|
assert row["session_key"] == "agent:newname:feishu:dm:chatA"
|
|
assert row["profile_name"] == "newname"
|
|
moved_db.close()
|
|
root_db2 = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
routing = root_db2.load_gateway_routing_entries(
|
|
scope=str(tmp_path / ".hermes" / "sessions"))
|
|
assert "agent:oldname:feishu:dm:chatA" not in routing
|
|
assert "agent:newname:feishu:dm:chatA" in routing
|
|
root_db2.close()
|
|
|
|
def test_rename_delegates_identity_migration_to_live_gateway(self, profile_env):
|
|
"""Under a live multiplexer the CLI must NOT rewrite the routing DB directly (the gateway holds
|
|
it in memory and would clobber the write); it delegates to the control verb instead."""
|
|
tmp_path = profile_env
|
|
create_profile("oldname", no_alias=True)
|
|
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
|
|
patch("hermes_cli.profiles._notify_multiplexer"), \
|
|
patch("gateway.control_socket.migrate_gateway_profile_identity",
|
|
return_value={"ok": True, "rekeyed": 1, "db": {}}) as verb, \
|
|
patch("hermes_state_registry.acquire") as acquire:
|
|
rename_profile("oldname", "newname")
|
|
|
|
# Delegated to the gateway; the CLI's own durable-rewrite branch never ran.
|
|
assert verb.call_count == 1
|
|
assert verb.call_args.args[1:] == ("oldname", "newname")
|
|
acquire.assert_not_called()
|
|
|
|
|
|
|
|
def test_migrate_identity_command_repairs_a_failed_live_migration(self, profile_env, capsys):
|
|
"""The failed-live-migration end state must be recoverable: `hermes profile
|
|
migrate-identity <old> <new>` rekeys the durable rows once no gateway holds the store, and
|
|
is idempotent (a second run has nothing left to rekey but still succeeds)."""
|
|
from hermes_cli.profile_cmd import cmd_profile
|
|
from hermes_state import SessionDB
|
|
from argparse import Namespace
|
|
tmp_path = profile_env
|
|
create_profile("oldname", no_alias=True)
|
|
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
|
|
pdb = SessionDB(old_dir / "state.db")
|
|
pdb.create_session(
|
|
"sess1", "feishu", session_key="agent:oldname:feishu:dm:chatA",
|
|
profile_name="oldname", chat_id="chatA", chat_type="dm")
|
|
pdb.close()
|
|
root_db = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
root_db.save_gateway_routing_entry(
|
|
"agent:oldname:feishu:dm:chatA",
|
|
json.dumps({"session_key": "agent:oldname:feishu:dm:chatA", "session_id": "sess1",
|
|
"origin": {"platform": "feishu", "chat_id": "chatA", "profile": "oldname"}}),
|
|
scope=str(tmp_path / ".hermes" / "sessions"))
|
|
root_db.close()
|
|
|
|
# Rename under a live multiplexer whose control verb answers nothing: the CLI warns and
|
|
# leaves the (in-memory-owned) store alone, so the rows still name the old profile.
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
|
patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
|
|
patch("hermes_cli.profiles._notify_multiplexer"), \
|
|
patch("gateway.control_socket.migrate_gateway_profile_identity", return_value=None):
|
|
rename_profile("oldname", "newname")
|
|
assert "hermes profile migrate-identity oldname newname" in capsys.readouterr().err
|
|
|
|
# Gateway restarted/stopped → the retry command repairs both stores.
|
|
with patch("hermes_cli.profiles._live_default_multiplexer", return_value=False):
|
|
cmd_profile(Namespace(profile_action="migrate-identity",
|
|
old_name="oldname", new_name="newname"))
|
|
assert "✓ Session/routing identity migrated" in capsys.readouterr().out
|
|
# Idempotent: nothing left to rekey, still a success.
|
|
cmd_profile(Namespace(profile_action="migrate-identity",
|
|
old_name="oldname", new_name="newname"))
|
|
|
|
moved_db = SessionDB(tmp_path / ".hermes" / "profiles" / "newname" / "state.db")
|
|
row = moved_db._read_one(
|
|
"SELECT session_key, profile_name FROM sessions WHERE id = ?", ("sess1",))
|
|
assert row is not None
|
|
assert row["session_key"] == "agent:newname:feishu:dm:chatA"
|
|
assert row["profile_name"] == "newname"
|
|
moved_db.close()
|
|
root_db2 = SessionDB(tmp_path / ".hermes" / "state.db")
|
|
routing = root_db2.load_gateway_routing_entries(
|
|
scope=str(tmp_path / ".hermes" / "sessions"))
|
|
assert "agent:oldname:feishu:dm:chatA" not in routing
|
|
assert "agent:newname:feishu:dm:chatA" in routing
|
|
root_db2.close()
|
|
|
|
|
|
def test_rename_accumulates_previous_names(self, profile_env):
|
|
create_profile("firstname", no_alias=True)
|
|
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
|
rename_profile("firstname", "secondname")
|
|
rename_profile("secondname", "thirdname")
|
|
|
|
info = next(p for p in list_profiles() if p.name == "thirdname")
|
|
assert info.previous_names == ["firstname", "secondname"]
|
|
|
|
def test_rename_succeeds_when_previous_name_write_fails(self, profile_env):
|
|
create_profile("oldname", no_alias=True)
|
|
|
|
# The history write is best-effort: it must never fail the rename.
|
|
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
|
patch("hermes_cli.profiles.write_profile_meta", side_effect=OSError("disk full")):
|
|
new_dir = rename_profile("oldname", "newname")
|
|
|
|
assert new_dir.is_dir()
|
|
|
|
|
|
|
|
class TestExportImport:
|
|
"""Tests for export_profile() / import_profile()."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# Default profile export / import
|
|
# ---------------------------------------------------------------
|
|
|
|
|
|
def test_export_default_includes_profile_data(self, profile_env, tmp_path):
|
|
"""Profile data files end up in the archive (credentials excluded)."""
|
|
# Write through HERMES_HOME, not get_profile_dir("default"): the latter resolves to the
|
|
# OPERATOR's real install whenever basetest sits inside it, so this test used to
|
|
# overwrite the live config.yaml / .env / MEMORY.md with its fixtures.
|
|
default_dir = profile_env / ".hermes"
|
|
(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" not in names # credentials excluded
|
|
assert "default/SOUL.md" in names
|
|
assert "default/memories/MEMORY.md" 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.
|
|
"""
|
|
# Same reason as above: never resolve the operator's real default home from a test.
|
|
default_dir = profile_env / ".hermes"
|
|
(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)
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestProfileIsolation
|
|
# ===================================================================
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers)
|
|
# ===================================================================
|
|
|
|
class TestInternalHelpers:
|
|
"""Tests for _get_profiles_root() and _get_default_hermes_home()."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_profile_docker(self, tmp_path, monkeypatch):
|
|
"""Profile created in Docker lands under HERMES_HOME/profiles/."""
|
|
docker_home = tmp_path / "opt" / "data"
|
|
docker_home.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(docker_home))
|
|
result = create_profile("orchestrator", no_alias=True)
|
|
expected = docker_home / "profiles" / "orchestrator"
|
|
assert result == expected
|
|
assert expected.is_dir()
|
|
|
|
|
|
|
|
|
|
# ===================================================================
|
|
# TestWriteProfileMetaDurability
|
|
# ===================================================================
|
|
|
|
class TestWriteProfileMetaDurability:
|
|
"""``profile.yaml`` must survive an interrupted ``write_profile_meta``.
|
|
|
|
``write_profile_meta`` is a read-modify-write whose docstring promises
|
|
"unspecified fields preserve existing values". Its read half swallows
|
|
any parse error and falls back to ``{}``, so a truncated profile.yaml is
|
|
not transient corruption — the *next* call reads ``{}`` and silently and
|
|
permanently drops every field the caller did not explicitly pass.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _seed(tmp_path):
|
|
profile_dir = tmp_path / "coder"
|
|
profile_dir.mkdir()
|
|
profiles.write_profile_meta(
|
|
profile_dir, description="Curated by hand", description_auto=False
|
|
)
|
|
return profile_dir
|
|
|
|
@staticmethod
|
|
def _interrupted_write(profile_dir):
|
|
"""Run a ``write_profile_meta`` whose serialization fails mid-call.
|
|
|
|
The pre-fix code called ``yaml.safe_dump``; ``utils.atomic_yaml_write``
|
|
calls ``yaml.dump``. Breaking both keeps this serializer-agnostic, so
|
|
it measures durability rather than the choice of entry point. A
|
|
scoped ``MonkeyPatch.context`` is used instead of the fixture so the
|
|
patch is reverted immediately, without touching the session-wide env
|
|
isolation that shares the function-scoped ``monkeypatch`` instance.
|
|
"""
|
|
def _boom(*args, **kwargs):
|
|
raise RuntimeError("simulated interruption mid-write")
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setattr(yaml, "safe_dump", _boom)
|
|
mp.setattr(yaml, "dump", _boom)
|
|
with pytest.raises(RuntimeError):
|
|
profiles.write_profile_meta(profile_dir, description_auto=True)
|
|
|
|
def test_failed_write_leaves_existing_file_intact(self, tmp_path):
|
|
profile_dir = self._seed(tmp_path)
|
|
path = profile_dir / "profile.yaml"
|
|
before = path.read_text(encoding="utf-8")
|
|
assert "Curated by hand" in before
|
|
|
|
self._interrupted_write(profile_dir)
|
|
|
|
# A truncating open() destroys the file before the dump ever runs.
|
|
assert path.read_text(encoding="utf-8") == before
|
|
|
|
def test_failed_write_does_not_silently_drop_unspecified_fields(self, tmp_path):
|
|
profile_dir = self._seed(tmp_path)
|
|
|
|
self._interrupted_write(profile_dir)
|
|
|
|
# The user retries; this call does not pass a description, so the
|
|
# documented merge contract requires the existing one to survive.
|
|
profiles.write_profile_meta(profile_dir, description_auto=True)
|
|
meta = profiles.read_profile_meta(profile_dir)
|
|
assert meta["description"] == "Curated by hand"
|
|
assert meta["description_auto"] is True
|
|
|
|
def test_emoji_description_is_written_as_real_utf8(self, tmp_path):
|
|
"""Astral-plane chars must not become ``\\UXXXXXXXX`` escapes (#51356).
|
|
|
|
Supersedes #51808, which fixed this symptom alone by adding
|
|
``allow_unicode=True``; ``atomic_yaml_write`` sets it internally.
|
|
"""
|
|
profile_dir = tmp_path / "wizard"
|
|
profile_dir.mkdir()
|
|
profiles.write_profile_meta(profile_dir, description="Code wizard 🧙 ✨")
|
|
|
|
raw = (profile_dir / "profile.yaml").read_text(encoding="utf-8")
|
|
assert "\\U" not in raw
|
|
assert "🧙" in raw
|
|
assert profiles.read_profile_meta(profile_dir)["description"] == "Code wizard 🧙 ✨"
|
|
|
|
def test_symlinked_profile_yaml_survives_the_write(self, tmp_path):
|
|
"""Guard on the conversion, not a behavior change.
|
|
|
|
Dotfile managers (chezmoi/stow) symlink profile.yaml into a tracked
|
|
repo. ``open(path, "w")`` wrote through the link; a naive
|
|
``os.replace`` would detach it. ``atomic_replace`` resolves the link
|
|
first (GitHub #16743), so the link must still be intact afterwards.
|
|
"""
|
|
profile_dir = tmp_path / "linked"
|
|
profile_dir.mkdir()
|
|
real_dir = tmp_path / "dotfiles"
|
|
real_dir.mkdir()
|
|
real = real_dir / "profile.yaml"
|
|
real.write_text("description: from dotfiles\n", encoding="utf-8")
|
|
(profile_dir / "profile.yaml").symlink_to(real)
|
|
|
|
profiles.write_profile_meta(profile_dir, description="updated")
|
|
|
|
assert (profile_dir / "profile.yaml").is_symlink()
|
|
assert "updated" in real.read_text(encoding="utf-8")
|
|
assert [p.name for p in profile_dir.iterdir() if p.name.endswith(".tmp")] == []
|
|
|
|
|
|
# ===================================================================
|
|
# Edge cases and additional coverage
|
|
# ===================================================================
|
|
|
|
class TestEdgeCases:
|
|
"""Additional edge-case tests."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_gateway_running_check_falls_back_to_runtime_state(self, profile_env):
|
|
"""A live gateway whose PID-file/lock check fails closed (separate-process
|
|
reader, e.g. the dashboard s6 service in Docker) is still detected via the
|
|
profile's gateway_state.json validated against the live process table.
|
|
|
|
Regression: the Profiles view used to show "Gateway stopped" while the
|
|
sidebar (which already has this fallback) showed "Gateway running" for the
|
|
same live gateway. See get_running_pid() short-circuiting on an
|
|
unheld runtime lock before it inspects the PID record.
|
|
"""
|
|
import os
|
|
import gateway.status as gw_status
|
|
from hermes_cli.profiles import _check_gateway_running
|
|
|
|
tmp_path = profile_env
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write a realistic gateway_state.json pointing at THIS live process with
|
|
# a gateway-shaped argv, so get_runtime_status_running_pid validates it.
|
|
live_pid = os.getpid()
|
|
(default_home / "gateway_state.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"pid": live_pid,
|
|
"kind": "hermes-gateway",
|
|
"argv": ["hermes", "gateway", "run"],
|
|
"start_time": gw_status._get_process_start_time(live_pid),
|
|
"gateway_state": "running",
|
|
"active_agents": 0,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Primary pid-file/lock check returns None (no lock held by this reader),
|
|
# exactly as it does for a separate-process dashboard. The fallback must
|
|
# then read the state file and confirm the gateway is alive by checking
|
|
# the recorded PID's live command line. In the real separate-process
|
|
# scenario that PID belongs to the live gateway, so mock its command
|
|
# line to a bare ``gateway run`` (this is the default/root home, which
|
|
# runs the gateway with no profile flag).
|
|
with patch("gateway.status.get_running_pid", return_value=None), patch(
|
|
"gateway.status._read_process_cmdline",
|
|
return_value="hermes gateway run --replace",
|
|
):
|
|
assert _check_gateway_running(default_home) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clone_from_named_profile(self, profile_env):
|
|
"""Clone config from a named (non-default) profile."""
|
|
tmp_path = profile_env
|
|
# Create source profile with config
|
|
source_dir = create_profile("source", no_alias=True)
|
|
(source_dir / "config.yaml").write_text("model: cloned")
|
|
(source_dir / ".env").write_text("SECRET=yes")
|
|
|
|
target_dir = create_profile(
|
|
"target", clone_from="source", clone_config=True, no_alias=True,
|
|
)
|
|
cloned_config = yaml.safe_load((target_dir / "config.yaml").read_text())
|
|
assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
|
assert cloned_config["model"] == "cloned"
|
|
assert (target_dir / ".env").read_text().strip() == "SECRET=yes"
|
|
|
|
|
|
|
|
class TestProfilesToServe:
|
|
"""profiles_to_serve(multiplex) — the gateway's profile-enumeration chokepoint."""
|
|
|
|
|
|
def test_off_returns_only_active_named(self, profile_env, monkeypatch):
|
|
# A named profile's gateway runs with HERMES_HOME pointing at the
|
|
# profile dir; get_active_profile_name() infers the name from there.
|
|
create_profile("coder", no_alias=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(get_profile_dir("coder")))
|
|
serve = profiles_to_serve(multiplex=False)
|
|
assert len(serve) == 1
|
|
assert serve[0][0] == "coder"
|
|
assert serve[0][1] == get_profile_dir("coder")
|
|
|
|
def test_on_returns_default_plus_all_named(self, profile_env):
|
|
create_profile("coder", no_alias=True)
|
|
create_profile("writer", no_alias=True)
|
|
serve = dict(profiles_to_serve(multiplex=True))
|
|
assert set(serve) == {"default", "coder", "writer"}
|
|
assert serve["default"] == _get_default_hermes_home()
|
|
assert serve["coder"] == get_profile_dir("coder")
|
|
|
|
# ------------------------------------------------------------------
|
|
# gateway.standalone: authored opt-out of the host multiplexer
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_standalone_profile_excluded_unless_included(self, profile_env):
|
|
"""A named profile that sets `gateway.standalone: true` is not served by the
|
|
host multiplexer, but callers that enumerate INSTALLED profiles still see it."""
|
|
create_profile("solo", no_alias=True)
|
|
create_profile("member", no_alias=True)
|
|
(get_profile_dir("solo") / "config.yaml").write_text("gateway:\n standalone: true\n")
|
|
serve = dict(profiles_to_serve(multiplex=True))
|
|
assert set(serve) == {"default", "member"}
|
|
served_all = dict(profiles_to_serve(multiplex=True, include_standalone=True))
|
|
assert set(served_all) == {"default", "solo", "member"}
|
|
|
|
def test_default_profile_with_key_still_served_with_one_warning(self, profile_env, caplog):
|
|
"""The default profile IS the host: the key is ignored (still served, never
|
|
standalone) with exactly one warning per process."""
|
|
profiles._STANDALONE_WARNED = False
|
|
default_home = _get_default_hermes_home()
|
|
(default_home / "config.yaml").write_text("gateway:\n standalone: true\n")
|
|
caplog.clear()
|
|
with caplog.at_level("WARNING", logger="hermes_cli.profiles"):
|
|
serve = dict(profiles_to_serve(multiplex=True))
|
|
assert profiles.profile_is_standalone(default_home) is False
|
|
assert profiles.profile_is_standalone(default_home) is False
|
|
assert list(serve) == ["default"]
|
|
assert serve["default"] == default_home
|
|
assert len([r for r in caplog.records if "ignored on the default profile" in r.message]) == 1
|
|
|
|
@pytest.mark.parametrize("content", ["gateway: [", "[]\n", "null\n", "", "gateway: false\n"])
|
|
def test_standalone_malformed_config_does_not_break_roster(self, profile_env, caplog, content):
|
|
create_profile("solo", no_alias=True)
|
|
home = get_profile_dir("solo")
|
|
(home / "config.yaml").write_text(content)
|
|
for _ in range(2):
|
|
assert profiles.profile_is_standalone(home) is False
|
|
assert "solo" in dict(profiles_to_serve(True))
|
|
warnings = [r for r in caplog.records if "Cannot read gateway.standalone" in r.message]
|
|
assert len(warnings) == (1 if content == "gateway: [" else 0)
|
|
|
|
@pytest.mark.parametrize("failure_at", ["stat", "read", "decode"])
|
|
def test_standalone_io_failure_is_bounded_and_recovers(self, profile_env, monkeypatch, caplog, failure_at):
|
|
from hermes_cli import config
|
|
|
|
create_profile("solo", no_alias=True)
|
|
home = get_profile_dir("solo")
|
|
cfg = home / "config.yaml"
|
|
cfg.write_text("gateway:\n standalone: true\n")
|
|
real_stat = Path.stat
|
|
|
|
def denied(path, *args, **kwargs):
|
|
if path == cfg:
|
|
raise PermissionError("denied")
|
|
return real_stat(path, *args, **kwargs)
|
|
|
|
def unreadable(*args, **kwargs):
|
|
if failure_at == "decode":
|
|
raise UnicodeError("decode failed")
|
|
raise PermissionError("denied")
|
|
|
|
with monkeypatch.context() as m:
|
|
if failure_at == "stat":
|
|
m.setattr(Path, "stat", denied)
|
|
else:
|
|
m.setattr(config, "read_user_config_raw", unreadable)
|
|
assert profiles.profile_is_standalone(home) is False
|
|
assert profiles.profile_is_standalone(home) is False
|
|
assert len([r for r in caplog.records if "Cannot read gateway.standalone" in r.message]) == 1
|
|
# Restoring access does not change mtime/size/inode; a read failure is not config.
|
|
assert profiles.profile_is_standalone(home) is True
|
|
|
|
def test_standalone_answer_is_per_home_and_memo_invalidates_on_replacement(self, profile_env):
|
|
"""A->B->A: signatures never cross homes; atomic replacement invalidates the memo."""
|
|
create_profile("alpha", no_alias=True)
|
|
create_profile("beta", no_alias=True)
|
|
alpha, beta = get_profile_dir("alpha"), get_profile_dir("beta")
|
|
(alpha / "config.yaml").write_text("gateway:\n standalone: true\n")
|
|
assert profiles.profile_is_standalone(alpha) is True
|
|
assert profiles.profile_is_standalone(beta) is False
|
|
assert profiles.profile_is_standalone(alpha) is True # memo hit, still True
|
|
cfg = alpha / "config.yaml"
|
|
replacement = alpha / "replacement.yaml"
|
|
replacement.write_text("gateway:\n standalone: false\n")
|
|
replacement.replace(cfg)
|
|
assert profiles.profile_is_standalone(alpha) is False
|
|
assert profiles.profile_is_standalone(beta) is False
|
|
assert profiles.profile_is_standalone(alpha) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_profile_env spelling preservation (#82581 junction follow-up)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveProfileEnvSpelling:
|
|
"""resolve_profile_env() keeps the configured HERMES_HOME spelling as
|
|
|
|
the launch root (junction installs) while preserving the pre-existing
|
|
profile-path handling and existence/validation semantics.
|
|
"""
|
|
|
|
def test_resolution_matrix_preserves_configured_spelling(self, monkeypatch, tmp_path):
|
|
"""Resolution matrix over the four pre-existing invariants: root env
|
|
-> <root>/profiles/<name>; profile-shaped env -> <root>/profiles/<name>
|
|
with no nesting; profile-shaped env + default -> <root>; custom roots
|
|
never fall back to the platform default.
|
|
"""
|
|
root = tmp_path / "configured-root"
|
|
custom = tmp_path / "custom-hermes"
|
|
for profile_dir in (root / "profiles" / "beta", root / "profiles" / "coder", custom / "profiles" / "beta"):
|
|
profile_dir.mkdir(parents=True)
|
|
(profile_dir / "config.yaml").write_text("{}\n") # identity marker: a bare dir does not resolve
|
|
cases = [
|
|
(root, "coder", root / "profiles" / "coder"),
|
|
(root / "profiles" / "alpha", "beta", root / "profiles" / "beta"),
|
|
(root / "profiles" / "alpha", "default", root),
|
|
(custom, "beta", custom / "profiles" / "beta"),
|
|
]
|
|
for env_home, profile, expected in cases:
|
|
monkeypatch.setenv("HERMES_HOME", str(env_home))
|
|
assert Path(resolve_profile_env(profile)) == expected
|
|
|
|
def test_missing_named_profile_still_raises(self, monkeypatch, tmp_path):
|
|
root = tmp_path / "configured-root"
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
with pytest.raises(FileNotFoundError):
|
|
resolve_profile_env("nope")
|
|
|
|
def test_unset_env_falls_back_to_default_root(self, monkeypatch):
|
|
# No HERMES_HOME: the platform default root applies (existing contract).
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
assert Path(resolve_profile_env("default")) == _get_default_hermes_home()
|
|
|
|
|
|
|
|
|
|
def _live_bot_desktop_launcher(profile_dir: Path):
|
|
"""A synthetic Bot Desktop launcher for ``profile_dir``: its own session (like launcher.sh) with the
|
|
identity file + env runtime.status() reads, so the profile op sees a running screen."""
|
|
import subprocess
|
|
from tools.bot_desktop import runtime
|
|
|
|
proc = subprocess.Popen(["sleep", "60"], start_new_session=True)
|
|
sd = profile_dir / "bot-desktop"
|
|
sd.mkdir()
|
|
(sd / "launcher.pid").write_text(f"{proc.pid} {runtime._create_time(proc.pid)}", encoding="utf-8")
|
|
(sd / "env").write_text("DISPLAY=:42\n", encoding="utf-8")
|
|
return proc
|
|
|
|
|
|
@pytest.mark.linux_only
|
|
@pytest.mark.parametrize("op", ["delete", "rename"])
|
|
def test_profile_delete_and_rename_stop_the_profiles_bot_desktop(profile_env, op):
|
|
"""Deleting or renaming a profile stops its gateway, and must stop its Bot Desktop launcher too: the
|
|
Xvnc/Xfce session otherwise keeps running against a directory that no longer exists (or now belongs to
|
|
another name), holding its display number and an rfb.sock nobody can reach through status()."""
|
|
import time
|
|
from tools.bot_desktop import runtime
|
|
|
|
profile_dir = create_profile("coder", no_alias=True)
|
|
proc = _live_bot_desktop_launcher(profile_dir)
|
|
# A human held the screen when the op ran. lease.json moves with a rename; left human-held it would
|
|
# fence the agent out of the renamed profile's next screen for a viewer that no longer exists.
|
|
(profile_dir / "bot-desktop" / "lease.json").write_text(
|
|
json.dumps({"holder": "human", "viewer_id": "gone", "since": 1.0, "epoch": 3, "reason": ""}), encoding="utf-8")
|
|
try:
|
|
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
|
|
patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
|
if op == "delete":
|
|
delete_profile("coder", yes=True)
|
|
else:
|
|
rename_profile("coder", "hacker")
|
|
# The runtime reaps what it kills (a later gateway holds no Popen for the launcher), so our own
|
|
# Popen may see the status already collected; liveness, not the exit code, is the contract.
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline and runtime._pid_alive(proc.pid):
|
|
time.sleep(0.05)
|
|
assert not runtime._pid_alive(proc.pid), "the launcher was not stopped by the profile op"
|
|
if op == "rename":
|
|
moved = json.loads((profile_dir.parent / "hacker" / "bot-desktop" / "lease.json").read_text(encoding="utf-8"))
|
|
assert moved["holder"] == "agent", "stale human lease survived the teardown"
|
|
finally:
|
|
proc.kill()
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["coder", "default"])
|
|
def test_export_leaves_the_bot_desktop_browser_profile_out(profile_env, tmp_path, name):
|
|
"""bot-desktop/ holds the screen's persistent Chromium profile (Cookies, Login Data: the bot's live web
|
|
sessions) plus sockets and X state. None of it belongs in an export archive meant to move a persona."""
|
|
profile_dir = create_profile(name, no_alias=True) if name != "default" else get_profile_dir("default")
|
|
(profile_dir / "config.yaml").write_text("model: test")
|
|
cookies = profile_dir / "bot-desktop" / "browser-profile" / "Default" / "Cookies"
|
|
cookies.parent.mkdir(parents=True)
|
|
cookies.write_bytes(b"SQLite format 3\x00")
|
|
output = tmp_path / "export" / f"{name}.tar.gz"
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
export_profile(name, str(output))
|
|
with tarfile.open(str(output), "r:gz") as tf:
|
|
names = tf.getnames()
|
|
assert f"{name}/config.yaml" in names
|
|
assert not [n for n in names if "bot-desktop" in n], names
|
|
|
|
# ===================================================================
|
|
# TestCloneAllExcludesRuntimeTrees
|
|
# ===================================================================
|
|
|
|
class TestCloneAllExcludesRuntimeTrees:
|
|
"""``--clone-all`` from the default profile must not copy the machine-scoped
|
|
runtime trees the local-models flow puts under ``~/.hermes``: ``models/``
|
|
(GGUF weights, tens of GB), ``runtimes/`` (llama.cpp binaries) and ``node/``
|
|
(managed Node). ``backup.py`` already excludes exactly these; the clone-all
|
|
ignore list had not followed.
|
|
"""
|
|
|
|
RUNTIME_TREES = ("models", "runtimes", "node")
|
|
|
|
def _seed(self, home):
|
|
(home / "models").mkdir(); (home / "models" / "big.gguf").write_bytes(b"\0" * 64)
|
|
(home / "runtimes" / "llamacpp" / "bin").mkdir(parents=True)
|
|
(home / "runtimes" / "llamacpp" / "bin" / "llama-server").write_text("bin")
|
|
(home / "node" / "bin").mkdir(parents=True)
|
|
(home / "node" / "bin" / "node").write_text("bin")
|
|
(home / "skills" / "greet").mkdir(parents=True)
|
|
(home / "skills" / "greet" / "SKILL.md").write_text("# greet\n")
|
|
(home / "config.yaml").write_text("model: test\n")
|
|
|
|
def test_ignore_drops_runtime_trees_only_at_the_default_root(self, profile_env):
|
|
default_home = profile_env / ".hermes"
|
|
self._seed(default_home)
|
|
# a skill that happens to carry a nested models/ dir is user data
|
|
(default_home / "skills" / "greet" / "models").mkdir()
|
|
ignore = _clone_all_copytree_ignore(default_home)
|
|
|
|
at_root = ignore(str(default_home), ["models", "runtimes", "node", "skills", "config.yaml"])
|
|
nested = ignore(str(default_home / "skills" / "greet"), ["models", "SKILL.md"])
|
|
|
|
assert set(at_root) == set(self.RUNTIME_TREES)
|
|
assert not nested
|
|
|
|
# Gated on the default profile: a named profile that really has a
|
|
# models/ dir of its own must not have it dropped when used as source.
|
|
source = create_profile("source", no_alias=True)
|
|
for tree in self.RUNTIME_TREES:
|
|
(source / tree).mkdir()
|
|
assert not _clone_all_copytree_ignore(source)(str(source), [*self.RUNTIME_TREES, "SOUL.md"])
|
|
|
|
|
|
def test_clone_all_from_default_skips_runtime_trees_but_keeps_the_rest(self, profile_env):
|
|
default_home = profile_env / ".hermes"
|
|
self._seed(default_home)
|
|
|
|
clone = create_profile("clone", clone_all=True, no_alias=True)
|
|
|
|
for name in self.RUNTIME_TREES:
|
|
assert not (clone / name).exists(), name
|
|
assert (clone / "skills" / "greet" / "SKILL.md").is_file()
|
|
assert (clone / "config.yaml").is_file()
|