Skip to content
Merged
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
21 changes: 20 additions & 1 deletion hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,19 @@ def _apply_external_secret_sources(home_path: Path) -> None:
home_key = str(Path(home_path).resolve())
if home_key in _APPLIED_HOMES:
return
_APPLIED_HOMES.add(home_key)

try:
cfg = _load_secrets_config(home_path)
except Exception: # noqa: BLE001 — config errors must not block startup
# Deliberately NOT marked applied: a malformed config.yaml would
# otherwise permanently disable secret loading for this process
# even after the user fixes the file (#40597).
return
if not cfg:
# No secrets section (or everything disabled at parse level). Not
# marked applied either — the re-parse is a cheap fast_safe_load and
# leaving the home unmarked lets a process pick up a config change
# on its next load_hermes_dotenv() call instead of never.
return

try:
Expand All @@ -415,6 +421,19 @@ def _apply_external_secret_sources(home_path: Path) -> None:
except Exception: # noqa: BLE001 — belt-and-braces; apply_all shouldn't raise
return

if not report.sources:
# Config parsed but no source is enabled: keep retrying cheaply
# (no fetch happens for disabled sources) so flipping a source on
# mid-process takes effect on the next call.
return

# A real fetch attempt happened (success OR error). Mark the home now
# so the 3-5 import-time load_hermes_dotenv() calls per startup don't
# re-fetch / re-print — error retries within one process are opt-in via
# reset_secret_source_cache(). Marking AFTER the attempt (not before,
# see #40597) is what lets the earlier failure paths stay retryable.
_APPLIED_HOMES.add(home_key)

if report.applied_any:
# Re-run the ASCII sanitization pass: vault values are
# user-supplied and might have the same copy-paste corruption as
Expand Down
135 changes: 135 additions & 0 deletions tests/test_env_loader_applied_homes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Regression tests for #40597: _APPLIED_HOMES must be marked AFTER a real
fetch attempt, so early failures (malformed config, disabled sources) stay
retryable within the process instead of being permanently skipped."""
from __future__ import annotations

from pathlib import Path

import pytest

from hermes_cli import env_loader


@pytest.fixture(autouse=True)
def _reset():
env_loader.reset_secret_source_cache()
yield
env_loader.reset_secret_source_cache()
from agent.secret_sources import registry
registry._reset_registry_for_tests()


def _write_enabled_config(home: Path):
(home / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: proj\n"
)


def test_malformed_config_does_not_permanently_skip(tmp_path, monkeypatch):
"""Config error on first call → fixed config on second call must apply."""
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("secrets: [unclosed") # malformed YAML

env_loader._apply_external_secret_sources(home)
assert str(home.resolve()) not in env_loader._APPLIED_HOMES

# User fixes the config; same process must now attempt the fetch.
_write_enabled_config(home)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")

import agent.secret_sources.bitwarden as bw
calls = {"n": 0}

def fake_fetch(**kwargs):
calls["n"] += 1
return {"NEW_KEY_40597": "val"}, []

monkeypatch.setattr(bw, "find_bws", lambda install_if_missing=True: home / "bws")
monkeypatch.setattr(bw, "fetch_bitwarden_secrets", fake_fetch)
monkeypatch.delenv("NEW_KEY_40597", raising=False)

from agent.secret_sources import registry
registry._reset_registry_for_tests()

env_loader._apply_external_secret_sources(home)
assert calls["n"] == 1
assert str(home.resolve()) in env_loader._APPLIED_HOMES
monkeypatch.delenv("NEW_KEY_40597", raising=False)


def test_no_secrets_section_does_not_mark_applied(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("model:\n provider: openrouter\n")
env_loader._apply_external_secret_sources(home)
assert str(home.resolve()) not in env_loader._APPLIED_HOMES


def test_disabled_sources_do_not_mark_applied(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
"secrets:\n bitwarden:\n enabled: false\n project_id: p\n"
)
from agent.secret_sources import registry
registry._reset_registry_for_tests()
env_loader._apply_external_secret_sources(home)
assert str(home.resolve()) not in env_loader._APPLIED_HOMES


def test_fetch_error_still_marks_applied(tmp_path, monkeypatch):
"""A real fetch attempt that FAILS still marks the home — otherwise every
import-time load_hermes_dotenv() would re-fetch and re-print the same
error 3-5x per startup."""
home = tmp_path / ".hermes"
home.mkdir()
_write_enabled_config(home)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.dead")

import agent.secret_sources.bitwarden as bw
calls = {"n": 0}

def boom(**kwargs):
calls["n"] += 1
raise RuntimeError("bws exited 1: network unreachable")

monkeypatch.setattr(bw, "find_bws", lambda install_if_missing=True: home / "bws")
monkeypatch.setattr(bw, "fetch_bitwarden_secrets", boom)

from agent.secret_sources import registry
registry._reset_registry_for_tests()

env_loader._apply_external_secret_sources(home)
env_loader._apply_external_secret_sources(home) # second call = no-op
assert calls["n"] == 1
assert str(home.resolve()) in env_loader._APPLIED_HOMES


def test_success_marks_applied_and_second_call_noop(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
_write_enabled_config(home)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
monkeypatch.delenv("KEY_OK_40597", raising=False)

import agent.secret_sources.bitwarden as bw
calls = {"n": 0}

def fake_fetch(**kwargs):
calls["n"] += 1
return {"KEY_OK_40597": "v"}, []

monkeypatch.setattr(bw, "find_bws", lambda install_if_missing=True: home / "bws")
monkeypatch.setattr(bw, "fetch_bitwarden_secrets", fake_fetch)

from agent.secret_sources import registry
registry._reset_registry_for_tests()

env_loader._apply_external_secret_sources(home)
env_loader._apply_external_secret_sources(home)
assert calls["n"] == 1
monkeypatch.delenv("KEY_OK_40597", raising=False)
Loading