fix(profiles): backfill .env for pre-existing profiles on hermes update (#45247)

Profiles created before #44792 have no .env. Now that the Channels/Keys
endpoints are profile-scoped (no os.environ fallback), those profiles
would show everything as unconfigured. hermes update now copies the
default install's .env into each named profile that lacks one (0600,
never overwrites, placeholder fallback when the root has no .env), so
existing users keep the credentials they were effectively running with.
This commit is contained in:
Teknium 2026-06-12 15:42:14 -07:00 committed by GitHub
parent 68536d4375
commit 135fe90166
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 0 deletions

View file

@ -8750,6 +8750,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception:
pass # profiles module not available or no profiles
# Backfill per-profile .env files for profiles created before the
# .env-seeding fix (#44792). Copies the default install's .env so
# those profiles keep the credentials they were effectively using.
try:
from hermes_cli.profiles import backfill_profile_envs
backfilled = backfill_profile_envs(quiet=True)
if backfilled:
print()
print(
f"→ Seeded .env for {len(backfilled)} profile(s) "
f"(copied from default): {', '.join(backfilled)}"
)
except Exception:
pass # profiles module not available or no profiles
# Sync Honcho host blocks to all profiles
try:
from plugins.memory.honcho.cli import sync_honcho_profiles_quiet

View file

@ -977,6 +977,58 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
return None
def backfill_profile_envs(quiet: bool = False) -> List[str]:
"""Give every named profile that predates per-profile ``.env`` files one.
Profiles created before the dashboard/CLI started seeding a ``.env``
(PR #44792) have none, so once the Channels/Keys endpoints became
profile-scoped those profiles stopped inheriting the root install's
credentials and showed everything as unconfigured. To avoid breaking
anyone on update, copy the DEFAULT install's ``.env`` into each named
profile that lacks one that preserves the effective credentials those
profiles were already running with (they previously read the root
``.env`` via the process environment). Users can then diverge per
profile from there.
Falls back to the placeholder header when the default install has no
``.env`` itself. Never overwrites an existing profile ``.env``.
Returns the list of profile names that received a backfilled ``.env``.
"""
backfilled: List[str] = []
profiles_root = _get_profiles_root()
if not profiles_root.is_dir():
return backfilled
default_env = _get_default_hermes_home() / ".env"
for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name):
continue
if entry.name == "default":
continue
env_path = entry / ".env"
if env_path.exists():
continue
try:
if default_env.is_file():
shutil.copy2(default_env, env_path)
else:
env_path.write_text(
"# Per-profile secrets for this Hermes profile.\n"
"# API keys and tokens set here override the shell environment.\n"
"# Behavioral settings belong in config.yaml, not here.\n",
encoding="utf-8",
)
os.chmod(str(env_path), 0o600)
backfilled.append(entry.name)
except OSError as e:
if not quiet:
print(f"⚠ Could not seed .env for profile '{entry.name}': {e}")
return backfilled
def delete_profile(name: str, yes: bool = False) -> Path:
"""Delete a profile, its wrapper script, and its gateway service.

View file

@ -33,6 +33,7 @@ from hermes_cli.profiles import (
seed_profile_skills,
has_bundled_skills_opt_out,
NO_BUNDLED_SKILLS_MARKER,
backfill_profile_envs,
)
@ -473,6 +474,60 @@ class TestNoSkillsOptOut:
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_never_overwrites_existing_profile_env(self, profile_env):
tmp_path = profile_env
(tmp_path / ".hermes" / ".env").write_text("KEY=root\n")
p = create_profile("hasenv", no_alias=True)
(p / ".env").write_text("KEY=mine\n")
backfilled = backfill_profile_envs(quiet=True)
assert backfilled == []
assert (p / ".env").read_text() == "KEY=mine\n"
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
# ===================================================================