diff --git a/agent/secret_sources/bitwarden.py b/agent/secret_sources/bitwarden.py index e025a0ca9b4e..1623b4c56d61 100644 --- a/agent/secret_sources/bitwarden.py +++ b/agent/secret_sources/bitwarden.py @@ -618,6 +618,13 @@ def apply_bitwarden_secrets( return result access_token = os.environ.get(access_token_env, "").strip() + + # `enabled: true` with neither a token nor a project configured means + # Bitwarden was never actually set up. Stay silent instead of warning on + # every startup; the errors below are actionable once setup is partial. + if not access_token and not project_id: + return result + if not access_token: result.error = ( f"secrets.bitwarden.enabled is true but {access_token_env} is " diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index c5e95a24dbcf..6c58f5fe8310 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -38,6 +38,11 @@ # config re-parse, and the ASCII sanitization sweep still ran every time. _APPLIED_HOMES: set[str] = set() +# Cross-process dedup for the Bitwarden status line. Startup can spawn child +# Python processes that each call load_hermes_dotenv() at import time; they +# inherit os.environ but not the in-process _APPLIED_HOMES guard above. +_BWS_STATUS_PRINTED_ENV = "_HERMES_BWS_STATUS_PRINTED" + def get_secret_source(env_var: str) -> str | None: """Return the label of the secret source that supplied ``env_var``, if any. @@ -63,6 +68,7 @@ def reset_secret_source_cache() -> None: that want to refresh after a config change. """ _APPLIED_HOMES.clear() + os.environ.pop(_BWS_STATUS_PRINTED_ENV, None) def format_secret_source_suffix(env_var: str) -> str: @@ -305,6 +311,15 @@ def _apply_external_secret_sources(home_path: Path) -> None: # came from BSM rather than .env. for name in result.applied: _SECRET_SOURCES[name] = "bitwarden" + + has_status = bool(result.applied or result.error or result.warnings) + if not has_status: + return + if os.environ.get(_BWS_STATUS_PRINTED_ENV): + return + os.environ[_BWS_STATUS_PRINTED_ENV] = "1" + + if result.applied: print( f" Bitwarden Secrets Manager: applied {len(result.applied)} " f"secret{'s' if len(result.applied) != 1 else ''} " diff --git a/tests/test_bitwarden_secrets.py b/tests/test_bitwarden_secrets.py index ac5057c18b80..f3ca96b0ba7c 100644 --- a/tests/test_bitwarden_secrets.py +++ b/tests/test_bitwarden_secrets.py @@ -500,6 +500,16 @@ def test_apply_disabled_returns_empty(): assert not result.error +def test_apply_unconfigured_is_silent(monkeypatch): + monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False) + result = bw.apply_bitwarden_secrets( + enabled=True, project_id="", auto_install=False + ) + assert result.ok + assert not result.applied + assert not result.error + + def test_apply_missing_token(monkeypatch): monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False) result = bw.apply_bitwarden_secrets( diff --git a/tests/test_env_loader_secret_sources.py b/tests/test_env_loader_secret_sources.py index 91c9d4c6e4f5..a229a4ec01d2 100644 --- a/tests/test_env_loader_secret_sources.py +++ b/tests/test_env_loader_secret_sources.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -121,6 +122,81 @@ def test_apply_external_secret_sources_noop_when_disabled(tmp_path, monkeypatch) assert env_loader.get_secret_source("ANTHROPIC_API_KEY") is None +def test_apply_external_secret_sources_dedupes_across_subprocesses( + tmp_path, monkeypatch, capsys +): + """An inherited parent marker suppresses duplicated child-process noise.""" + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "secrets:\n" + " bitwarden:\n" + " enabled: true\n" + " project_id: test-project\n" + " access_token_env: BWS_ACCESS_TOKEN\n", + encoding="utf-8", + ) + + from agent.secret_sources.bitwarden import FetchResult + + def _fake_apply(**_kwargs): + return FetchResult( + error=( + "secrets.bitwarden.enabled is true but BWS_ACCESS_TOKEN is " + "not set. Run `hermes secrets bitwarden setup`." + ) + ) + + import agent.secret_sources.bitwarden as bw_module + + monkeypatch.setattr(bw_module, "apply_bitwarden_secrets", _fake_apply) + monkeypatch.setenv(env_loader._BWS_STATUS_PRINTED_ENV, "1") + + env_loader._apply_external_secret_sources(tmp_path) + + captured = capsys.readouterr() + assert "Bitwarden Secrets Manager" not in captured.err + + +def test_apply_external_secret_sources_prints_warning_once_then_sets_marker( + tmp_path, monkeypatch, capsys +): + """The first process prints the status line and marks inherited environ.""" + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "secrets:\n" + " bitwarden:\n" + " enabled: true\n" + " project_id: test-project\n" + " access_token_env: BWS_ACCESS_TOKEN\n", + encoding="utf-8", + ) + + from agent.secret_sources.bitwarden import FetchResult + + err_text = ( + "secrets.bitwarden.enabled is true but BWS_ACCESS_TOKEN is " + "not set. Run `hermes secrets bitwarden setup`." + ) + + def _fake_apply(**_kwargs): + return FetchResult(error=err_text) + + import agent.secret_sources.bitwarden as bw_module + + monkeypatch.setattr(bw_module, "apply_bitwarden_secrets", _fake_apply) + monkeypatch.delenv(env_loader._BWS_STATUS_PRINTED_ENV, raising=False) + + env_loader._apply_external_secret_sources(tmp_path) + + captured = capsys.readouterr() + assert err_text in captured.err + assert os.environ.get(env_loader._BWS_STATUS_PRINTED_ENV) == "1" + + def test_apply_external_secret_sources_dedupes_within_process(tmp_path, monkeypatch): """``load_hermes_dotenv()`` is called at module-import time from several hot modules (cli.py, hermes_cli/main.py, run_agent.py, ...). The