hermes-agent/tests/hermes_cli/test_profiles.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

819 lines
30 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 io
import os
import shutil
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 (
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,
validate_alias_name,
rename_profile,
export_profile,
import_profile,
_get_profiles_root,
_get_default_hermes_home,
seed_profile_skills,
has_bundled_skills_opt_out,
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"
# ===================================================================
# 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_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."
# ===================================================================
# 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()
# has_bundled_skills_opt_out() agrees
assert has_bundled_skills_opt_out(profile_dir) is True
# 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()) == []
def test_delete_marker_re_enables_seeding(self, profile_env, monkeypatch):
"""Deleting .no-bundled-skills opts the profile back in."""
import subprocess as _sp
profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True)
assert has_bundled_skills_opt_out(profile_dir) is True
# First call: opted out, returns skipped dict without touching subprocess
called = []
monkeypatch.setattr(
"subprocess.run",
lambda *a, **kw: (called.append(a), _sp.CompletedProcess(
args=a, returncode=0, stdout='{"copied": []}', stderr=""
))[1],
)
r1 = seed_profile_skills(profile_dir, quiet=True)
assert r1.get("skipped_opt_out") is True
assert called == []
# Delete marker → next call runs the real path
(profile_dir / NO_BUNDLED_SKILLS_MARKER).unlink()
assert has_bundled_skills_opt_out(profile_dir) is False
r2 = seed_profile_skills(profile_dir, quiet=True)
assert r2 == {"copied": []}
assert len(called) == 1
# ===================================================================
# 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_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]
# ===================================================================
# 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
# ===================================================================
# 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()."""
def test_windows_checks_bat_extension(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
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("sys.platform", "darwin")
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
def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script, remove_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, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
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, monkeypatch):
# ~/.local/bin commonly holds unrelated binaries; they must not match.
monkeypatch.setattr("sys.platform", "darwin")
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, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
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"
# ===================================================================
# TestExportImport
# ===================================================================
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)."""
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" 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.
"""
default_dir = get_profile_dir("default")
(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
# ===================================================================
class TestProfileIsolation:
"""Verify that two profiles have completely separate paths."""
def test_separate_config_paths(self, profile_env):
create_profile("alpha", no_alias=True)
create_profile("beta", no_alias=True)
alpha_dir = get_profile_dir("alpha")
beta_dir = get_profile_dir("beta")
assert alpha_dir / "config.yaml" != beta_dir / "config.yaml"
assert str(alpha_dir) not in str(beta_dir)
# ===================================================================
# TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers)
# ===================================================================
class TestInternalHelpers:
"""Tests for _get_profiles_root() and _get_default_hermes_home()."""
def test_default_hermes_home_docker(self, tmp_path, monkeypatch):
"""In Docker, _get_default_hermes_home() returns HERMES_HOME itself."""
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))
home = _get_default_hermes_home()
assert home == docker_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()
# ===================================================================
# 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")