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 # ===================================================================