From 135fe90166e8dd54739c9756eea3074832db826c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:42:14 -0700 Subject: [PATCH] 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. --- hermes_cli/main.py | 16 +++++++++ hermes_cli/profiles.py | 52 +++++++++++++++++++++++++++++ tests/hermes_cli/test_profiles.py | 55 +++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 841d8bc947c5a..f2ad89f7601cb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index f30eb70650e11..50e5bbeabbc96 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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. diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index c38b1f0655dc6..2a23b648baa79 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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 # ===================================================================