From dc7cbab92b810a0b695dd470fcd0ddf7bd7a57fb Mon Sep 17 00:00:00 2001 From: Fangliquan Date: Sat, 25 Jul 2026 23:23:54 +0800 Subject: [PATCH 1/7] fix(hermes_cli): seed empty .env on profile distribution install Without a per-profile .env sentinel, hermes update's backfill copies the default profile's API keys into a freshly installed distribution profile. --- hermes_cli/profile_distribution.py | 19 +++++++ tests/hermes_cli/test_profile_distribution.py | 54 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index c981015d4b03..af456d4efbe3 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -61,6 +61,7 @@ from __future__ import annotations +import os import re import shutil import subprocess @@ -638,6 +639,24 @@ 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", + ) + os.chmod(str(env_path), 0o600) + except OSError: + pass # best-effort — save_env_value creates the file on demand + if create_alias: collision = check_alias_collision(plan.manifest.name) if collision is None: diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py index 82dd1de5bd2d..217a7bd6123e 100644 --- a/tests/hermes_cli/test_profile_distribution.py +++ b/tests/hermes_cli/test_profile_distribution.py @@ -32,6 +32,7 @@ update_distribution, write_manifest, ) +from hermes_cli.profiles import backfill_profile_envs # --------------------------------------------------------------------------- @@ -334,6 +335,59 @@ def test_install_emits_env_example_when_manifest_has_env(self, profile_env): assert example.is_file() assert "OPENAI_API_KEY" in example.read_text() + 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 "API_KEY" not in content + assert "TOKEN" not in content + 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 + 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_enforces_hermes_requires(self, profile_env, monkeypatch): # Pin current Hermes version to something well below the requirement import hermes_cli From 040ffbe0d975371b1225de3fee05183767d88b83 Mon Sep 17 00:00:00 2001 From: Fangliquan Date: Sat, 25 Jul 2026 23:30:59 +0800 Subject: [PATCH 2/7] fix(hermes_cli): fail closed when distribution install cannot seed .env If the sentinel write fails after the profile tree is created, refuse the install and remove a fresh target so hermes update cannot backfill default credentials into an env-less profile. --- hermes_cli/profile_distribution.py | 13 +++++++++++- tests/hermes_cli/test_profile_distribution.py | 21 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index af456d4efbe3..08698400ef21 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -653,9 +653,20 @@ def install_distribution( "# Behavioral settings belong in config.yaml, not here.\n", encoding="utf-8", ) + except OSError as e: + # Fresh installs must not leave a profiles// tree without + # the sentinel — backfill would copy default credentials into 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 " + "(hermes update would backfill default credentials)." + ) from e + try: os.chmod(str(env_path), 0o600) except OSError: - pass # best-effort — save_env_value creates the file on demand + pass # mode bits are best-effort on some platforms if create_alias: collision = check_alias_collision(plan.manifest.name) diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py index 217a7bd6123e..85e5d5b6c2c4 100644 --- a/tests/hermes_cli/test_profile_distribution.py +++ b/tests/hermes_cli/test_profile_distribution.py @@ -352,8 +352,11 @@ def test_install_seeds_empty_env_sentinel(self, profile_env): line.startswith("#") or not line.strip() for line in content.splitlines() ) - assert "API_KEY" not in content - assert "TOKEN" not in content + 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): @@ -388,6 +391,20 @@ def test_install_force_preserves_existing_env(self, profile_env): "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_install_enforces_hermes_requires(self, profile_env, monkeypatch): # Pin current Hermes version to something well below the requirement import hermes_cli From b21ba003f3d3516979919acc7ba7b3244540f4f7 Mon Sep 17 00:00:00 2001 From: Fangliquan Date: Sat, 25 Jul 2026 23:36:20 +0800 Subject: [PATCH 3/7] fix(hermes_cli): do not backfill default secrets into distribution profiles Pre-fix dist installs lack .env; hermes update must seed a placeholder instead of copying the default profile's API keys into them. --- hermes_cli/profiles.py | 23 +++++++++++++------ tests/hermes_cli/test_profile_distribution.py | 20 ++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 0ab0562deae1..0758217706b7 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1238,6 +1238,11 @@ 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``. @@ -1249,6 +1254,11 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: return backfilled 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): @@ -1258,16 +1268,15 @@ 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) 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") os.chmod(str(env_path), 0o600) backfilled.append(entry.name) except OSError as e: diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py index 85e5d5b6c2c4..2a6fdda087af 100644 --- a/tests/hermes_cli/test_profile_distribution.py +++ b/tests/hermes_cli/test_profile_distribution.py @@ -405,6 +405,26 @@ def boom(self, *args, **kwargs): 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 + 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 From 7afd76ae77b7b95437c85a1575e8880c2bdc86d5 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 1 Aug 2026 08:56:37 +0800 Subject: [PATCH 4/7] fix(hermes_cli): distinguish copied vs placeholder in update env backfill summary Distribution profiles now get a placeholder .env; stop claiming every seeded profile was copied from default. --- hermes_cli/main.py | 15 +++--- hermes_cli/profiles.py | 50 ++++++++++++++++--- tests/hermes_cli/test_profile_distribution.py | 3 ++ tests/hermes_cli/test_profiles.py | 32 ++++++++++-- 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index eebb4771d2c2..71154282240e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11939,18 +11939,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 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 0758217706b7..2c21ee5aaf17 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1225,7 +1225,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`` @@ -1246,12 +1278,14 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: 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 = ( @@ -1275,15 +1309,19 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: try: if default_env.is_file() and not is_distribution: shutil.copy2(default_env, env_path) + copied.append(entry.name) else: 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]: diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py index 2a6fdda087af..32a17cf5e8b2 100644 --- a/tests/hermes_cli/test_profile_distribution.py +++ b/tests/hermes_cli/test_profile_distribution.py @@ -372,6 +372,7 @@ def test_install_env_seed_blocks_default_secret_backfill(self, profile_env): 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 @@ -418,6 +419,8 @@ def test_backfill_seeds_empty_env_for_distribution_profile(self, profile_env): 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( diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index c61bc36dd4be..8df30bb70626 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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 @@ -522,7 +524,8 @@ 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 @@ -535,7 +538,7 @@ def test_never_overwrites_existing_profile_env(self, profile_env): backfilled = backfill_profile_envs(quiet=True) - assert backfilled == [] + assert not backfilled assert (p / ".env").read_text() == "KEY=mine\n" def test_placeholder_when_default_has_no_env(self, profile_env): @@ -544,7 +547,8 @@ def test_placeholder_when_default_has_no_env(self, profile_env): 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() @@ -552,7 +556,27 @@ def test_placeholder_when_default_has_no_env(self, profile_env): ) 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 # =================================================================== From e02d92be7cd4a32dac465f28ad6812ae1ca42322 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 1 Aug 2026 08:56:43 +0800 Subject: [PATCH 5/7] chore(contributors): add contributor map fangliquan@qq.com --- contributors/emails/fangliquan@qq.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/fangliquan@qq.com diff --git a/contributors/emails/fangliquan@qq.com b/contributors/emails/fangliquan@qq.com new file mode 100644 index 000000000000..58b0678f6bd7 --- /dev/null +++ b/contributors/emails/fangliquan@qq.com @@ -0,0 +1 @@ +fangliquanflq From cb445414c5fdc1bbbdf356c62683ccf63371eb01 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Sat, 1 Aug 2026 09:08:37 +0800 Subject: [PATCH 6/7] fix(hermes_cli): correct dist .env seed failure error wording Distribution-aware backfill no longer copies default secrets; stop claiming that in the install fail-closed message. --- hermes_cli/profile_distribution.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index 453f61535417..ee9650fe293d 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -658,13 +658,14 @@ def install_distribution( ) except OSError as e: # Fresh installs must not leave a profiles// tree without - # the sentinel — backfill would copy default credentials into it. + # 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 " - "(hermes update would backfill default credentials)." + "Refusing to leave the profile without a .env sentinel." ) from e try: os.chmod(str(env_path), 0o600) From 11f780ad0a0a10aa6db680279e93c944dd32d7a5 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Tue, 4 Aug 2026 11:33:37 +0800 Subject: [PATCH 7/7] chore(contributors): normalize email mapping line endings to LF --- contributors/emails/fangliquan@qq.com | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contributors/emails/fangliquan@qq.com b/contributors/emails/fangliquan@qq.com index 2d2320ef90ba..b1e421acaef0 100644 --- a/contributors/emails/fangliquan@qq.com +++ b/contributors/emails/fangliquan@qq.com @@ -1,2 +1,2 @@ -fangliquanflq -# PR #73031 author email +fangliquanflq +# PR #73031 author email