fix: migrate cloned profile configs (#46345)

This commit is contained in:
Teknium 2026-06-14 16:30:23 -07:00 committed by GitHub
parent 2a14e8957d
commit a829e04d62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 73 additions and 5 deletions

View file

@ -455,6 +455,37 @@ def remove_wrapper_script(name: str) -> bool:
return False
def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
"""Bring a copied profile config.yaml up to the current schema.
Profile creation can clone a config file that predates schema tracking (no
``_config_version``) or that is simply older than the running Hermes. If we
leave it untouched, the first desktop/doctor view of the new profile shows a
scary ``v0 latest`` warning even though we just created the profile. Scope
the normal migration pipeline to the new profile and keep it non-interactive.
"""
config_path = profile_dir / "config.yaml"
if not config_path.exists():
return
try:
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_cli.config import check_config_version, migrate_config
token = set_hermes_home_override(str(profile_dir))
try:
current_ver, latest_ver = check_config_version()
if current_ver < latest_ver:
migrate_config(interactive=False, quiet=True)
finally:
reset_hermes_home_override(token)
except Exception:
# Profile creation should not fail because an old copied config could
# not be migrated. The next `hermes doctor --fix` can still surface the
# detailed error in the target profile.
pass
def find_alias_for_profile(profile_name: str) -> Optional[str]:
"""Return the alias name of the wrapper that activates *profile_name*, or None.
@ -910,6 +941,14 @@ def create_profile(
except OSError:
pass # best-effort — the feature still works via the empty skills/ dir
# Cloned configs can be older than the running Hermes (or predate schema
# tracking entirely). Migrate config-only clones immediately so
# desktop/status surfaces don't warn that a just-created profile is
# v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the
# explicit runtime/history stripping above.
if not clone_all:
_migrate_profile_config_if_outdated(profile_dir)
# Persist description if the caller provided one. Done last so a
# partial-create failure doesn't strand a description file in an
# incomplete profile.

View file

@ -12,6 +12,7 @@ from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
import yaml
from hermes_cli.profiles import (
normalize_profile_name,
@ -35,6 +36,7 @@ from hermes_cli.profiles import (
NO_BUNDLED_SKILLS_MARKER,
backfill_profile_envs,
)
from hermes_cli.config import DEFAULT_CONFIG
# ---------------------------------------------------------------------------
@ -206,10 +208,26 @@ class TestCreateProfile:
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
assert (profile_dir / "config.yaml").read_text() == "model: test"
assert (profile_dir / ".env").read_text() == "KEY=val"
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_migrates_legacy_config_version(self, profile_env):
tmp_path = profile_env
default_home = tmp_path / ".hermes"
(default_home / "config.yaml").write_text(
"model:\n provider: openrouter\n",
encoding="utf-8",
)
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"]["provider"] == "openrouter"
def test_clone_config_copies_source_skills(self, profile_env):
tmp_path = profile_env
default_home = tmp_path / ".hermes"
@ -1453,8 +1471,10 @@ class TestEdgeCases:
target_dir = create_profile(
"target", clone_from="source", clone_config=True, no_alias=True,
)
assert (target_dir / "config.yaml").read_text() == "model: cloned"
assert (target_dir / ".env").read_text() == "SECRET=yes"
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"
def test_delete_clears_active_profile(self, profile_env):
"""Deleting the active profile resets active to default."""

View file

@ -8,11 +8,13 @@ from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
import yaml
from hermes_cli.config import (
reload_env,
redact_key,
OPTIONAL_ENV_VARS,
DEFAULT_CONFIG,
)
@ -2708,6 +2710,10 @@ class TestNewEndpoints:
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
(get_hermes_home() / "config.yaml").write_text(
"model:\n provider: openrouter\n",
encoding="utf-8",
)
default_skill = get_hermes_home() / "skills" / "custom" / "new-skill"
default_skill.mkdir(parents=True)
(default_skill / "SKILL.md").write_text("---\nname: new-skill\n---\n", encoding="utf-8")
@ -2718,8 +2724,11 @@ class TestNewEndpoints:
)
assert resp.status_code == 200
cloned_skill = get_hermes_home() / "profiles" / "cloned" / "skills" / "custom" / "new-skill" / "SKILL.md"
cloned_root = get_hermes_home() / "profiles" / "cloned"
cloned_skill = cloned_root / "skills" / "custom" / "new-skill" / "SKILL.md"
assert cloned_skill.exists()
cloned_config = yaml.safe_load((cloned_root / "config.yaml").read_text(encoding="utf-8"))
assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"]
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
assert profiles["cloned"]["skill_count"] == 1