Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions hermes_cli/profile_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@

from __future__ import annotations

import os
import re
import shutil
import subprocess
Expand Down Expand Up @@ -679,6 +680,36 @@ def install_distribution(
preserve_config=False,
)

# Seed an empty per-profile .env sentinel (same as create_profile).
# Distributions deliberately exclude credentials from the payload, so
# without this file `hermes update`'s backfill_profile_envs would
# treat a brand-new install as a pre-#44792 legacy profile and copy
# the default profile's API keys into it.
env_path = plan.target_dir / ".env"
if not env_path.exists():
try:
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",
)
except OSError as e:
# Fresh installs must not leave a profiles/<name>/ tree without
# the sentinel. Distribution-aware backfill writes a placeholder
# (not default secrets), but a half-created env-less tree is
# still a broken install — fail closed and remove it.
if not plan.existing:
shutil.rmtree(plan.target_dir, ignore_errors=True)
raise DistributionError(
f"Failed to seed per-profile .env at {env_path}: {e}. "
"Refusing to leave the profile without a .env sentinel."
) from e
try:
os.chmod(str(env_path), 0o600)
except OSError:
pass # mode bits are best-effort on some platforms

if create_alias:
collision = check_alias_collision(plan.manifest.name)
if collision is None:
Expand Down
73 changes: 60 additions & 13 deletions hermes_cli/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -1226,7 +1226,39 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
return None


def backfill_profile_envs(quiet: bool = False) -> List[str]:
@dataclass(frozen=True)
class BackfillEnvResult:
"""Outcome of ``backfill_profile_envs``: which profiles got which seed."""

copied: Tuple[str, ...] = ()
placeholder: Tuple[str, ...] = ()

@property
def names(self) -> List[str]:
return list(self.copied) + list(self.placeholder)

def __bool__(self) -> bool:
return bool(self.copied or self.placeholder)

def __contains__(self, item: object) -> bool:
return item in self.copied or item in self.placeholder

def __len__(self) -> int:
return len(self.copied) + len(self.placeholder)


def format_backfill_env_summary(result: BackfillEnvResult) -> str:
"""Human-readable ``hermes update`` line for a backfill result."""
parts: List[str] = []
if result.copied:
parts.append(f"copied from default: {', '.join(result.copied)}")
if result.placeholder:
parts.append(f"placeholder: {', '.join(result.placeholder)}")
detail = "; ".join(parts) if parts else "no changes"
return f"→ Seeded .env for {len(result)} profile(s) ({detail})"


def backfill_profile_envs(quiet: bool = False) -> BackfillEnvResult:
"""Give every named profile that predates per-profile ``.env`` files one.

Profiles created before the dashboard/CLI started seeding a ``.env``
Expand All @@ -1239,17 +1271,29 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]:
``.env`` via the process environment). Users can then diverge per
profile from there.

Distribution-installed profiles are the exception: they never shared the
default profile's ``.env`` (credentials are excluded from the payload), so
copying secrets into them would break isolation. Those get an empty
placeholder instead.

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``.
Returns a ``BackfillEnvResult`` distinguishing copied versus placeholder
seeds (so ``hermes update`` can report accurately).
"""
backfilled: List[str] = []
copied: List[str] = []
placeholder_names: List[str] = []
profiles_root = _get_profiles_root()
if not profiles_root.is_dir():
return backfilled
return BackfillEnvResult()

default_env = _get_default_hermes_home() / ".env"
placeholder = (
"# 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"
)

for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir() or not _PROFILE_ID_RE.match(entry.name):
Expand All @@ -1259,23 +1303,26 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]:
env_path = entry / ".env"
if env_path.exists():
continue
# Distribution installs never shared the default profile's .env.
# Copying secrets into them breaks credential isolation (same class
# as a missing install-time sentinel).
is_distribution = (entry / "distribution.yaml").is_file()
try:
if default_env.is_file():
if default_env.is_file() and not is_distribution:
shutil.copy2(default_env, env_path)
copied.append(entry.name)
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",
)
env_path.write_text(placeholder, encoding="utf-8")
placeholder_names.append(entry.name)
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
return BackfillEnvResult(
copied=tuple(copied),
placeholder=tuple(placeholder_names),
)


def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
Expand Down
15 changes: 8 additions & 7 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4415,18 +4415,19 @@ def _cmd_update_impl(args, gateway_mode: bool):
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.
# .env-seeding fix (#44792). Legacy named profiles get a copy of the
# default install's .env; distribution profiles get a placeholder so
# credentials stay isolated.
try:
from hermes_cli.profiles import backfill_profile_envs
from hermes_cli.profiles import (
backfill_profile_envs,
format_backfill_env_summary,
)

backfilled = backfill_profile_envs(quiet=True)
if backfilled:
print()
print(
f"→ Seeded .env for {len(backfilled)} profile(s) "
f"(copied from default): {', '.join(backfilled)}"
)
print(format_backfill_env_summary(backfilled))
except Exception:
pass # profiles module not available or no profiles

Expand Down
94 changes: 94 additions & 0 deletions tests/hermes_cli/test_profile_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
update_distribution,
write_manifest,
)
from hermes_cli.profiles import backfill_profile_envs


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -352,6 +353,99 @@ def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path)
plan_install(str(bogus), tmp_path / "work", override_name="x")


def test_install_seeds_empty_env_sentinel(self, profile_env):
"""Fresh dist installs must get a placeholder .env like create_profile.

Without it, hermes update's backfill_profile_envs copies the default
profile's secrets into the new install (credential isolation break).
"""
import stat

staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="seeded")
env_path = plan.target_dir / ".env"
assert env_path.is_file()
content = env_path.read_text(encoding="utf-8")
assert all(
line.startswith("#") or not line.strip()
for line in content.splitlines()
)
assert not any(
(not line.startswith("#")) and ("=" in line)
for line in content.splitlines()
if line.strip()
)
assert stat.S_IMODE(env_path.stat().st_mode) == 0o600

def test_install_env_seed_blocks_default_secret_backfill(self, profile_env):
"""Installed dist profile must not receive default .env on backfill."""
default_home = profile_env / ".hermes"
(default_home / ".env").write_text(
"OPENAI_API_KEY=sk-SECRET-DEFAULT\nTELEGRAM_BOT_TOKEN=tok-SECRET\n",
encoding="utf-8",
)
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="telem")

backfilled = backfill_profile_envs(quiet=True)

assert "telem" not in backfilled
assert not backfilled
content = (plan.target_dir / ".env").read_text(encoding="utf-8")
assert "sk-SECRET-DEFAULT" not in content
assert "tok-SECRET" not in content
assert all(
line.startswith("#") or not line.strip()
for line in content.splitlines()
)

def test_install_force_preserves_existing_env(self, profile_env):
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="keepenv")
(plan.target_dir / ".env").write_text("OPENAI_API_KEY=sk-user\n", encoding="utf-8")

install_distribution(str(staged), name="keepenv", force=True)

assert (plan.target_dir / ".env").read_text(encoding="utf-8") == (
"OPENAI_API_KEY=sk-user\n"
)

def test_install_raises_if_env_seed_write_fails(self, profile_env, monkeypatch):
staged = _make_staging_dir(profile_env, "src")
real_write = Path.write_text

def boom(self, *args, **kwargs):
if self.name == ".env":
raise OSError("disk full")
return real_write(self, *args, **kwargs)

monkeypatch.setattr(Path, "write_text", boom)
with pytest.raises(DistributionError, match=r"seed per-profile \.env"):
install_distribution(str(staged), name="noseed")
assert not (profile_env / ".hermes" / "profiles" / "noseed").exists()

def test_backfill_seeds_empty_env_for_distribution_profile(self, profile_env):
"""Pre-fix dist installs (no .env) must not receive default secrets."""
default_home = profile_env / ".hermes"
(default_home / ".env").write_text(
"OPENAI_API_KEY=sk-SECRET-DEFAULT\n", encoding="utf-8"
)
staged = _make_staging_dir(profile_env, "src")
plan = install_distribution(str(staged), name="legacy-dist")
(plan.target_dir / ".env").unlink() # simulate pre-fix install

backfilled = backfill_profile_envs(quiet=True)

assert "legacy-dist" in backfilled
assert backfilled.placeholder == ("legacy-dist",)
assert backfilled.copied == ()
content = (plan.target_dir / ".env").read_text(encoding="utf-8")
assert "sk-SECRET-DEFAULT" not in content
assert all(
line.startswith("#") or not line.strip()
for line in content.splitlines()
)

def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
# Pin current Hermes version to something well below the requirement
import hermes_cli
Expand Down
40 changes: 37 additions & 3 deletions tests/hermes_cli/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@
seed_profile_skills,
has_bundled_skills_opt_out,
NO_BUNDLED_SKILLS_MARKER,
BackfillEnvResult,
backfill_profile_envs,
format_backfill_env_summary,
profiles_to_serve,
)
from hermes_cli.config import DEFAULT_CONFIG
Expand Down Expand Up @@ -229,27 +231,59 @@ def test_copies_default_env_into_envless_profiles(self, profile_env):

backfilled = backfill_profile_envs(quiet=True)

assert sorted(backfilled) == ["old1", "old2"]
assert backfilled.copied == ("old1", "old2")
assert backfilled.placeholder == ()
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 not 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"]
assert backfilled.placeholder == ("noroot",)
assert backfilled.copied == ()
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) == []
assert not backfill_profile_envs(quiet=True)

def test_format_summary_distinguishes_copied_and_placeholder(self):
only_copied = format_backfill_env_summary(
BackfillEnvResult(copied=("old1", "old2"))
)
assert "copied from default: old1, old2" in only_copied
assert "placeholder" not in only_copied

only_placeholder = format_backfill_env_summary(
BackfillEnvResult(placeholder=("legacy-dist",))
)
assert "placeholder: legacy-dist" in only_placeholder
assert "copied from default" not in only_placeholder

mixed = format_backfill_env_summary(
BackfillEnvResult(copied=("old1",), placeholder=("dist1",))
)
assert "copied from default: old1" in mixed
assert "placeholder: dist1" in mixed
assert "2 profile(s)" in mixed


# ===================================================================
Expand Down
Loading