Files
hermes-agent/tests/agent/test_bitwarden_secrets.py
ethernet 612d542281 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
2026-09-14 15:38:34 -04:00

406 lines
12 KiB
Python

"""Hermetic tests for the Bitwarden Secrets Manager integration.
Secret/cache behavior stays offline. PM acquisition and executable invocation
are exercised by tests/pm/test_security_consumers.py.
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
import sys
import time
from pathlib import Path
from unittest import mock
import pytest
# Make the worktree importable without depending on the installed wheel.
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from agent.secret_sources import bitwarden as bw # noqa: E402
@pytest.fixture(autouse=True)
def _reset_caches():
bw._reset_cache_for_tests()
yield
bw._reset_cache_for_tests()
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Point Hermes at an isolated home directory."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Some modules cache get_hermes_home; clear if needed.
import hermes_constants
if hasattr(hermes_constants, "_HERMES_HOME_CACHE"):
hermes_constants._HERMES_HOME_CACHE = None # type: ignore[attr-defined]
return home
# ---------------------------------------------------------------------------
# fetch_bitwarden_secrets
# ---------------------------------------------------------------------------
def _fake_bws_payload(items):
return json.dumps(items)
def test_fetch_server_url_sets_env(monkeypatch, tmp_path):
"""server_url must be plumbed into the subprocess as BWS_SERVER_URL."""
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([{"key": "K", "value": "v"}])
captured_env = {}
def fake_run(cmd, **kwargs):
captured_env.update(kwargs["env"])
return mock.Mock(returncode=0, stdout=payload, stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
bw.fetch_bitwarden_secrets(
access_token="0.t",
project_id="p",
binary=fake_binary,
use_cache=False,
server_url="https://vault.bitwarden.eu",
)
assert captured_env.get("BWS_SERVER_URL") == "https://vault.bitwarden.eu"
# ---------------------------------------------------------------------------
# apply_bitwarden_secrets — the public entry point used by env_loader
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# env_loader integration
# ---------------------------------------------------------------------------
def test_env_loader_calls_bsm_when_enabled(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: 'proj-1'\n"
" access_token_env: 'BWS_ACCESS_TOKEN'\n"
" cache_ttl_seconds: 0\n"
" override_existing: false\n"
" auto_install: false\n"
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
monkeypatch.delenv("MY_BSM_KEY", raising=False)
called = {"n": 0}
def fake_fetch(**kwargs):
called["n"] += 1
assert kwargs["project_id"] == "proj-1"
return {"MY_BSM_KEY": "from-bsm"}, []
monkeypatch.setattr(
"agent.secret_sources.bitwarden.find_bws",
lambda **_kw: Path("/fake/bws"),
)
monkeypatch.setattr(
"agent.secret_sources.bitwarden.fetch_bitwarden_secrets",
fake_fetch,
)
from agent.secret_sources import registry as reg_module
reg_module._reset_registry_for_tests()
from hermes_cli.env_loader import _apply_external_secret_sources
_apply_external_secret_sources(home)
assert called["n"] == 1
assert os.environ.get("MY_BSM_KEY") == "from-bsm"
# ---------------------------------------------------------------------------
# Disk-persisted cache (cross-process — speeds up back-to-back CLI invocations)
# ---------------------------------------------------------------------------
def test_disk_cache_key_mismatch_triggers_refetch(monkeypatch, tmp_path):
"""Disk cache entry written by a different token/project is ignored."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([{"key": "K1", "value": "v1"}])
call_count = {"n": 0}
def fake_run(*a, **kw):
call_count["n"] += 1
return mock.Mock(returncode=0, stdout=payload, stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
bw._reset_cache_for_tests(home)
# Write a cache entry for a DIFFERENT token/project pair
cache_path = bw._disk_cache_path(home)
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps({
"key": "deadbeef00000000|other-project|",
"secrets": {"OTHER": "should-not-leak"},
"fetched_at": time.time(),
}))
secrets, _ = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=300, home_path=home,
)
# We must NOT have used the foreign cache entry
assert secrets == {"K1": "v1"}
assert "OTHER" not in secrets
assert call_count["n"] == 1
@pytest.mark.platforms("linux")
def test_encrypted_cache_writes_without_plaintext(monkeypatch, tmp_path):
"""Encrypted cache stores last-good secrets without raw values on disk."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([{"key": "K1", "value": "secret-value"}])
monkeypatch.setattr(
subprocess,
"run",
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
)
bw._reset_cache_for_tests(home)
# A successful encrypted write must remove a pre-existing legacy plaintext
# cache from the migration path.
legacy_key = (bw._token_fingerprint("0.t"), "proj-1", "")
bw._DISK_CACHE.write(
legacy_key,
bw._CachedFetch(secrets={"K1": "legacy"}, fetched_at=time.time()),
300,
home,
)
assert bw._disk_cache_path(home).exists()
secrets, warnings = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)
assert secrets == {"K1": "secret-value"}
assert warnings == []
assert not bw._disk_cache_path(home).exists()
cache_path = bw._encrypted_disk_cache_path(home)
assert cache_path.exists()
mode = stat.S_IMODE(os.stat(cache_path).st_mode)
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
text = cache_path.read_text()
assert "secret-value" not in text
assert "0.t" not in text
payload_disk = json.loads(text)
assert set(payload_disk.keys()) == {
"version", "key", "salt", "nonce", "ciphertext",
}
assert not bw._disk_cache_path(home).exists()
def test_encrypted_cache_falls_back_on_network_error(monkeypatch, tmp_path):
"""A fresh-enough encrypted cache is used when BWS is unreachable."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
calls = {"n": 0}
def fake_run(*a, **kw):
calls["n"] += 1
if calls["n"] == 1:
return mock.Mock(
returncode=0,
stdout=_fake_bws_payload([{"key": "K1", "value": "cached"}]),
stderr="",
)
return mock.Mock(
returncode=1,
stdout="",
stderr="Error: network is unreachable",
)
monkeypatch.setattr(subprocess, "run", fake_run)
bw._reset_cache_for_tests(home)
first, _ = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)
assert first == {"K1": "cached"}
bw._CACHE.clear()
second, warnings = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)
assert second == {"K1": "cached"}
assert calls["n"] == 2
assert len(warnings) == 1
assert "stale ENCRYPTED disk cache" in warnings[0]
assert "bws live fetch failed" in warnings[0]
# ---------------------------------------------------------------------------
# Stale disk cache fallback when live bws fetch fails
# ---------------------------------------------------------------------------
def _seed_stale_disk_cache(home, *, secrets, age_seconds, project_id="proj-1",
access_token="0.t", server_url=""):
"""Populate the disk cache as if a successful fetch happened `age_seconds`
ago. Writes the JSON payload directly (same shape the shared DiskCache
reads/writes) rather than going through DiskCache.write, since that
would honor cache_ttl_seconds and refuse to persist an already-"stale"
entry — this needs to land on disk regardless of TTL."""
cache_key = (
bw._token_fingerprint(access_token), project_id, server_url,
)
cache_path = bw._disk_cache_path(home)
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps({
"key": bw._cache_key_str(cache_key),
"secrets": secrets,
"fetched_at": time.time() - age_seconds,
}))
def test_stale_disk_cache_returned_when_bws_fails(monkeypatch, tmp_path):
"""When bws fails and the disk cache is stale, return the stale secrets
with a warning rather than raising."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
bw._reset_cache_for_tests(home)
# Seed a stale (older than TTL) disk cache from a previous successful fetch
_seed_stale_disk_cache(home, secrets={"OPENAI_API_KEY": "sk-old"},
age_seconds=3600)
# Now simulate a BWS network failure
def fail_run(*a, **kw):
return mock.Mock(returncode=1, stdout="",
stderr="Error: dns resolution failed")
monkeypatch.setattr(subprocess, "run", fail_run)
secrets, warnings = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=300, home_path=home,
)
assert secrets == {"OPENAI_API_KEY": "sk-old"}
assert len(warnings) == 1
assert "stale disk cache" in warnings[0]
assert "dns resolution failed" in warnings[0]
def test_stale_fallback_skipped_on_auth_failure(monkeypatch, tmp_path):
"""An AUTH_FAILED bws error must raise, not serve stale secrets — a bad
access token indicates a real credential problem the caller needs to
see, not a transient outage worth papering over."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
bw._reset_cache_for_tests(home)
_seed_stale_disk_cache(home, secrets={"K1": "v1"}, age_seconds=3600)
monkeypatch.setattr(
subprocess, "run",
lambda *a, **kw: mock.Mock(returncode=1, stdout="",
stderr="Error: unauthorized (401)"),
)
with pytest.raises(RuntimeError, match="unauthorized"):
bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=300, home_path=home,
)