Skip to content
Closed
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
102 changes: 102 additions & 0 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,103 @@ def load_hermes_dotenv(
return loaded


def _string_map(value: object) -> dict[str, str]:
"""Coerce a user config mapping to a clean str->str env-var map."""
if not isinstance(value, dict):
return {}
out: dict[str, str] = {}
for source, target in value.items():
source_s = str(source).strip()
target_s = str(target).strip()
if source_s and target_s:
out[source_s] = target_s
return out


def _string_list(value: object) -> list[str]:
"""Coerce a scalar/list config value to a clean list of strings."""
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, (list, tuple, set)):
return [str(part).strip() for part in value if str(part).strip()]
return []


def _result_list_remove(result: object, attr: str, key: str) -> None:
values = getattr(result, attr, None)
if isinstance(values, list):
while key in values:
values.remove(key)


def _result_list_append(result: object, attr: str, key: str) -> None:
values = getattr(result, attr, None)
if isinstance(values, list) and key not in values:
values.append(key)


def _prune_secret_result_key(result: object, key: str) -> None:
os.environ.pop(key, None)
secrets = getattr(result, "secrets", None)
if isinstance(secrets, dict):
secrets.pop(key, None)
_result_list_remove(result, "applied", key)
_result_list_remove(result, "skipped", key)


def _apply_bitwarden_env_map(result: object, bw_cfg: dict, *, override_existing: bool) -> None:
"""Map BWS secret aliases to runtime env names for profile-specific secrets.

Example config::

secrets:
bitwarden:
env_map:
DISCORD_BOT_TOKEN_MEOS_ACADEMIC: DISCORD_BOT_TOKEN
prune_env_prefixes:
- DISCORD_BOT_TOKEN_

This lets several profile-specific secrets live in one BWS project without
exporting every alias into the long-lived process environment. The selected
alias is materialized as the runtime env var that Hermes already consumes;
aliases are then pruned from both ``os.environ`` and the printed/applied
result metadata.
"""
mapping = _string_map(bw_cfg.get("env_map"))
if not mapping:
return

secrets = getattr(result, "secrets", None)
if not isinstance(secrets, dict):
secrets = {}

for source, target in mapping.items():
value = secrets.get(source) or os.environ.get(source)
if not value:
continue
if override_existing or not os.environ.get(target):
os.environ[target] = str(value)
result_secrets = getattr(result, "secrets", None)
if isinstance(result_secrets, dict):
result_secrets[target] = str(value)
_result_list_remove(result, "skipped", target)
_result_list_append(result, "applied", target)

prune_keys = set(mapping) | set(_string_list(bw_cfg.get("prune_env_keys")))
prune_prefixes = _string_list(bw_cfg.get("prune_env_prefixes"))
if prune_prefixes:
for key in list(os.environ):
if any(key.startswith(prefix) for prefix in prune_prefixes):
prune_keys.add(key)
for key in list(secrets):
if any(str(key).startswith(prefix) for prefix in prune_prefixes):
prune_keys.add(str(key))

targets = set(mapping.values())
for key in sorted(prune_keys - targets):
_prune_secret_result_key(result, key)


def _apply_managed_env() -> None:
"""Apply the managed-scope .env last, with override, so it beats user/shell.

Expand Down Expand Up @@ -326,6 +423,11 @@ def _apply_external_secret_sources(home_path: Path) -> None:
server_url=str(bw_cfg.get("server_url", "") or "").strip(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main no longer has this direct Bitwarden-result path: hermes_cli/env_loader.py:341-346 calls registry.apply_all(), which owns the actual environment writes. Please move aliasing into the active SecretSource/registry flow so it preserves precedence, protected-variable handling, and provenance.

home_path=home_path,
)
_apply_bitwarden_env_map(
result,
bw_cfg,
override_existing=bool(bw_cfg.get("override_existing", False)),
)

if result.applied:
# Re-run the ASCII sanitization pass: BSM values are user-supplied
Expand Down
95 changes: 95 additions & 0 deletions tests/hermes_cli/test_env_loader_bitwarden_env_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for Bitwarden env alias mapping in Hermes env loader."""

from __future__ import annotations

import os

from agent.secret_sources.bitwarden import FetchResult
from hermes_cli import env_loader
from agent.secret_sources import bitwarden as bw


def test_bitwarden_env_map_materializes_target_and_prunes_aliases(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" access_token_env: BWS_ACCESS_TOKEN\n"
" project_id: project-123\n"
" env_map:\n"
" DISCORD_BOT_TOKEN_MEOS_DEV: DISCORD_BOT_TOKEN\n"
" prune_env_prefixes:\n"
" - DISCORD_BOT_TOKEN_\n",
encoding="utf-8",
)

def fake_apply_bitwarden_secrets(**kwargs):
assert kwargs["project_id"] == "project-123"
secrets = {
"DISCORD_BOT_TOKEN_MEOS_DEV": "dev-token",
"DISCORD_BOT_TOKEN_MEOS_ACADEMIC": "academic-token",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mocks the legacy apply_bitwarden_secrets entry point. Current startup tests stub fetch_bitwarden_secrets and exercise _apply_external_secret_sources() through the registry; use that live path so the test covers the implementation users run.

"OPENAI_API_KEY": "openai-key",
}
for key, value in secrets.items():
os.environ[key] = value
return FetchResult(
secrets=secrets,
applied=list(secrets),
)

monkeypatch.setattr(bw, "apply_bitwarden_secrets", fake_apply_bitwarden_secrets)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "access")
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
monkeypatch.delenv("DISCORD_BOT_TOKEN_MEOS_DEV", raising=False)
monkeypatch.delenv("DISCORD_BOT_TOKEN_MEOS_ACADEMIC", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
env_loader.reset_secret_source_cache()
env_loader._SECRET_SOURCES.clear()

env_loader._apply_external_secret_sources(home)

assert env_loader.os.environ["DISCORD_BOT_TOKEN"] == "dev-token"
assert env_loader.os.environ["OPENAI_API_KEY"] == "openai-key"
assert "DISCORD_BOT_TOKEN_MEOS_DEV" not in env_loader.os.environ
assert "DISCORD_BOT_TOKEN_MEOS_ACADEMIC" not in env_loader.os.environ
assert env_loader.get_secret_source("DISCORD_BOT_TOKEN") == "bitwarden"
assert env_loader.get_secret_source("OPENAI_API_KEY") == "bitwarden"
assert env_loader.get_secret_source("DISCORD_BOT_TOKEN_MEOS_DEV") is None


def test_bitwarden_env_map_respects_existing_target_without_override(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" access_token_env: BWS_ACCESS_TOKEN\n"
" project_id: project-123\n"
" override_existing: false\n"
" env_map:\n"
" DISCORD_BOT_TOKEN_MEOS_DEV: DISCORD_BOT_TOKEN\n",
encoding="utf-8",
)

def fake_apply_bitwarden_secrets(**kwargs):
os.environ["DISCORD_BOT_TOKEN_MEOS_DEV"] = "dev-token"
return FetchResult(
secrets={"DISCORD_BOT_TOKEN_MEOS_DEV": "dev-token"},
applied=["DISCORD_BOT_TOKEN_MEOS_DEV"],
)

monkeypatch.setattr(bw, "apply_bitwarden_secrets", fake_apply_bitwarden_secrets)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "access")
monkeypatch.setenv("DISCORD_BOT_TOKEN", "existing-token")
env_loader.reset_secret_source_cache()
env_loader._SECRET_SOURCES.clear()

env_loader._apply_external_secret_sources(home)

assert env_loader.os.environ["DISCORD_BOT_TOKEN"] == "existing-token"
assert "DISCORD_BOT_TOKEN_MEOS_DEV" not in env_loader.os.environ
assert env_loader.get_secret_source("DISCORD_BOT_TOKEN_MEOS_DEV") is None
assert env_loader.get_secret_source("DISCORD_BOT_TOKEN") is None