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
15 changes: 11 additions & 4 deletions agent/secret_sources/bitwarden.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@
from pathlib import Path
from typing import Dict, List, Optional, Tuple

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
DiskCache,
Expand Down Expand Up @@ -375,6 +371,13 @@ def _b64d(text: str) -> bytes:

def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
"""Derive the local cache encryption key from the bootstrap BWS token."""
# Keep the native cryptography extension lazy. Most CLI commands import
# this module while building argparse, even though only encrypted-cache
# reads/writes need it. Eagerly importing it maps ``_rust.pyd`` into a
# Windows updater and prevents uv from replacing that file (#73381).
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

return HKDF(
algorithm=hashes.SHA256(),
length=32,
Expand All @@ -397,6 +400,8 @@ def _write_encrypted_disk_cache(
"""
path = _encrypted_disk_cache_path(home_path)
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
try:
Expand Down Expand Up @@ -459,6 +464,8 @@ def _read_encrypted_disk_cache(
return None
path = _encrypted_disk_cache_path(home_path)
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return None
Expand Down
7 changes: 6 additions & 1 deletion hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ def load_hermes_dotenv(
*,
hermes_home: str | os.PathLike | None = None,
project_env: str | os.PathLike | None = None,
load_external_secrets: bool = True,
) -> list[Path]:
"""Load Hermes environment files with user config taking precedence.

Expand All @@ -471,6 +472,9 @@ def load_hermes_dotenv(
- project `.env` acts as a dev fallback and only fills missing values when
the user env exists.
- if no user env exists, the project `.env` also overrides stale shell vars.
- callers that only maintain the installation can set
``load_external_secrets=False`` to avoid loading optional secret-manager
dependencies into the process that replaces that same environment.
"""
loaded: list[Path] = []

Expand Down Expand Up @@ -509,7 +513,8 @@ def load_hermes_dotenv(
_load_dotenv_with_fallback(project_env_path, override=not loaded)
loaded.append(project_env_path)

_apply_external_secret_sources(home_path)
if load_external_secrets:
_apply_external_secret_sources(home_path)
_apply_managed_env()

# config.yaml is the documented source of truth for terminal.* settings,
Expand Down
12 changes: 11 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,17 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None:
from hermes_cli.config import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv

load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
# Updating dependencies must not import optional secret-manager libraries into
# the updater process before ``uv`` replaces the environment. On Windows,
# Bitwarden's cryptography import maps ``_rust.pyd`` and the parent updater then
# prevents its own child installer from replacing that file (#73381). Profile
# flags have already been stripped above, so the first remaining argument is
# the authoritative argparse subcommand. Dotenv/managed config still loads;
# only external secret fetches are unnecessary for installation maintenance.
load_hermes_dotenv(
project_env=PROJECT_ROOT / ".env",
load_external_secrets=sys.argv[1:2] != ["update"],
)

# Bridge security.redact_secrets from config.yaml → HERMES_REDACT_SECRETS env
# var BEFORE hermes_logging imports agent.redact (which snapshots the flag at
Expand Down
119 changes: 119 additions & 0 deletions tests/hermes_cli/test_update_secret_import_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Regression coverage for Windows updater self-locking native dependencies.

External secret backends are useful during normal Hermes startup, but the
updater must not load them before replacing packages in its own environment.
On Windows, importing Bitwarden's ``cryptography`` dependency maps
``_rust.pyd`` into the updater process and prevents ``uv`` from replacing it.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

import pytest

from hermes_cli import env_loader


REPO_ROOT = Path(__file__).resolve().parents[2]


def _probe_startup_modules(
tmp_path: Path, argv: list[str], *, run_main: bool = False
) -> set[str]:
home = tmp_path / "hermes-home"
home.mkdir()
(home / "config.yaml").write_text(
"""\
secrets:
bitwarden:
enabled: true
project_id: test-project
""",
encoding="utf-8",
)

dispatch = (
"hermes_main.cmd_update = lambda _args: 0\n"
"hermes_main.main()\n"
if run_main
else ""
)
probe = (
"import json, sys\n"
f"sys.argv = {argv!r}\n"
"import hermes_cli.main as hermes_main\n"
f"{dispatch}"
"print('LOADED_MODULES=' + json.dumps(sorted(sys.modules)))\n"
)
result = subprocess.run(
[sys.executable, "-c", probe],
capture_output=True,
text=True,
timeout=120,
cwd=REPO_ROOT,
env={**os.environ, "HERMES_HOME": str(home), "BWS_ACCESS_TOKEN": ""},
)
assert result.returncode == 0, result.stderr
line = next(
line for line in result.stdout.splitlines() if line.startswith("LOADED_MODULES=")
)
return set(json.loads(line.removeprefix("LOADED_MODULES=")))


def test_update_startup_does_not_import_bitwarden_or_cryptography(tmp_path):
loaded = _probe_startup_modules(tmp_path, ["hermes", "update", "--check"])

assert "agent.secret_sources.bitwarden" not in loaded
assert not any(
name == "cryptography" or name.startswith("cryptography.") for name in loaded
)


def test_complete_update_dispatch_does_not_import_cryptography(tmp_path):
"""Building every CLI parser used to re-import Bitwarden via secrets_cli."""
loaded = _probe_startup_modules(
tmp_path,
["hermes", "update", "--check"],
run_main=True,
)

assert not any(
name == "cryptography" or name.startswith("cryptography.") for name in loaded
)


def test_normal_startup_still_loads_enabled_external_secret_source(tmp_path):
loaded = _probe_startup_modules(tmp_path, ["hermes", "chat"])

assert "agent.secret_sources.bitwarden" in loaded


@pytest.mark.parametrize("external_secrets", [True, False])
def test_dotenv_loading_is_preserved_when_external_secrets_are_skipped(
tmp_path, monkeypatch, external_secrets
):
home = tmp_path / "hermes-home"
home.mkdir()
env_file = home / ".env"
env_file.write_text("UPDATE_TEST_VALUE=from-dotenv\n", encoding="utf-8")
applied = []
monkeypatch.delenv("UPDATE_TEST_VALUE", raising=False)
monkeypatch.setattr(
env_loader,
"_apply_external_secret_sources",
lambda path: applied.append(path),
)

loaded = env_loader.load_hermes_dotenv(
hermes_home=home,
load_external_secrets=external_secrets,
)

assert loaded == [env_file]
assert os.environ["UPDATE_TEST_VALUE"] == "from-dotenv"
assert applied == ([home] if external_secrets else [])