mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
68536d4375
commit
135fe90166
3 changed files with 123 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue