mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(secrets): Bitwarden Secrets Manager integration with lazy bws install (#30035)
* feat(secrets): Bitwarden Secrets Manager integration with lazy bws install
Pull API keys from Bitwarden Secrets Manager at process startup
instead of storing them all in plaintext in ~/.hermes/.env. One
bootstrap token (BWS_ACCESS_TOKEN) replaces N per-provider keys, and
rotating a credential becomes a single change in the Bitwarden web
app.
Bitwarden defaults to source of truth: secrets pulled from BSM
overwrite any matching env vars on startup so rotations actually
take effect. Set secrets.bitwarden.override_existing: false in
config.yaml to invert.
The bws binary is auto-downloaded into ~/.hermes/bin/bws on first
use (pinned to v2.0.0, SHA-256 verified against the GitHub release
checksum file). No apt, brew, or sudo required.
New surfaces:
hermes secrets bitwarden setup — interactive wizard
hermes secrets bitwarden status — config + binary + token state
hermes secrets bitwarden sync — dry-run fetch / --apply exports
hermes secrets bitwarden disable — flip enabled: false
hermes secrets bitwarden install — just download the binary
Failures (missing binary, bad token, no network) never block Hermes
startup — they emit a one-line warning to stderr and continue with
whatever credentials .env already had.
Docs: website/docs/user-guide/secrets/{index,bitwarden}.md
Tests: tests/test_bitwarden_secrets.py (26 tests, hermetic — bws
subprocess and HTTP downloads fully mocked)
* chore(infographic): add bitwarden-secrets-manager bento-grid retro-pop-grid
Generated for PR #30035 — Bitwarden Secrets Manager integration.
Style picked via pick_pr_infographic_style.py rotation:
layout: bento-grid
style: retro-pop-grid
aspect: 1:1 square
Saved at infographic/bitwarden-secrets-manager/infographic.png
This commit is contained in:
parent
18cd1e5c72
commit
552e9c7881
13 changed files with 1921 additions and 1 deletions
13
agent/secret_sources/__init__.py
Normal file
13
agent/secret_sources/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""External secret source integrations.
|
||||
|
||||
A secret source is anything that can supply environment-variable-shaped
|
||||
credentials at process startup, _after_ ~/.hermes/.env has loaded. By
|
||||
default sources are non-destructive: they only set values for env vars
|
||||
that aren't already present, so .env and shell exports continue to win.
|
||||
|
||||
Currently shipped:
|
||||
|
||||
- ``bitwarden`` — Bitwarden Secrets Manager (`bws` CLI). See
|
||||
``agent.secret_sources.bitwarden`` for the integration and
|
||||
``hermes_cli.secrets_cli`` for the user-facing setup wizard.
|
||||
"""
|
||||
515
agent/secret_sources/bitwarden.py
Normal file
515
agent/secret_sources/bitwarden.py
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
"""Bitwarden Secrets Manager (`bws` CLI) integration.
|
||||
|
||||
Hermes pulls API keys from Bitwarden Secrets Manager at process startup
|
||||
so they don't have to live in plaintext in ``~/.hermes/.env``.
|
||||
|
||||
Design summary
|
||||
--------------
|
||||
|
||||
* The ``bws`` binary is auto-installed into ``<hermes_home>/bin/bws`` on
|
||||
first use. Hermes pins one version (``_BWS_VERSION``) and downloads
|
||||
the matching asset from the official GitHub Releases page, verifying
|
||||
the SHA-256 against the release's published checksum file.
|
||||
* The access token is stored in ``~/.hermes/.env`` as
|
||||
``BWS_ACCESS_TOKEN`` (or whatever name the user picked in
|
||||
``secrets.bitwarden.access_token_env``). This is the one
|
||||
bootstrap secret — every other provider key can live in Bitwarden.
|
||||
* Pulling secrets is a single ``bws secret list <project_id>
|
||||
--output json`` call. We cache the result in-process for
|
||||
``cache_ttl_seconds`` so back-to-back ``hermes`` invocations don't
|
||||
hammer the API.
|
||||
* Failures NEVER block Hermes startup. Missing binary, no network,
|
||||
expired token, etc. all emit a one-line warning and continue with
|
||||
whatever credentials ``.env`` already had.
|
||||
|
||||
The module is intentionally subprocess-driven rather than going through
|
||||
the ``bitwarden-sdk-secrets`` Python package: one cross-platform binary
|
||||
is easier to lazy-install than a wheels-with-Rust-extension dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pinned upstream version. Bump in a follow-up PR — never auto-resolve
|
||||
# "latest" because upstream release shape (asset names, CLI flags) is
|
||||
# allowed to change between majors and we want updates to be deliberate.
|
||||
_BWS_VERSION = "2.0.0"
|
||||
|
||||
_BWS_RELEASE_BASE = (
|
||||
f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{_BWS_VERSION}"
|
||||
)
|
||||
_BWS_CHECKSUM_NAME = f"bws-sha256-checksums-{_BWS_VERSION}.txt"
|
||||
|
||||
# How long to wait for bws subprocesses and HTTP downloads, in seconds.
|
||||
_BWS_DOWNLOAD_TIMEOUT = 60
|
||||
_BWS_RUN_TIMEOUT = 30
|
||||
|
||||
# In-process cache so repeated load_hermes_dotenv() calls (CLI startup,
|
||||
# gateway hot-reload, test suites) don't re-fetch from BSM.
|
||||
_CacheKey = Tuple[str, str] # (access_token_fingerprint, project_id)
|
||||
_CACHE: Dict[_CacheKey, "_CachedFetch"] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedFetch:
|
||||
secrets: Dict[str, str]
|
||||
fetched_at: float
|
||||
|
||||
def is_fresh(self, ttl_seconds: float) -> bool:
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
return (time.time() - self.fetched_at) < ttl_seconds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""Outcome of a single BSM pull."""
|
||||
|
||||
secrets: Dict[str, str] = field(default_factory=dict)
|
||||
applied: List[str] = field(default_factory=list) # set into os.environ
|
||||
skipped: List[str] = field(default_factory=list) # already set, not overridden
|
||||
warnings: List[str] = field(default_factory=list) # non-fatal issues
|
||||
error: Optional[str] = None # fatal: nothing was fetched
|
||||
binary_path: Optional[Path] = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.error is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery + lazy install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hermes_bin_dir() -> Path:
|
||||
"""Where Hermes stores its managed binaries. Profile-aware."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "bin"
|
||||
|
||||
|
||||
def find_bws(*, install_if_missing: bool = False) -> Optional[Path]:
|
||||
"""Return a path to a usable ``bws`` binary, or None.
|
||||
|
||||
Resolution order:
|
||||
1. ``<hermes_home>/bin/bws`` (our managed copy — preferred)
|
||||
2. ``shutil.which("bws")`` (system PATH)
|
||||
|
||||
When ``install_if_missing`` is True and neither resolves, this calls
|
||||
:func:`install_bws` to download and verify the pinned version.
|
||||
"""
|
||||
managed = _hermes_bin_dir() / _platform_binary_name()
|
||||
if managed.exists() and os.access(managed, os.X_OK):
|
||||
return managed
|
||||
|
||||
system = shutil.which("bws")
|
||||
if system:
|
||||
return Path(system)
|
||||
|
||||
if install_if_missing:
|
||||
try:
|
||||
return install_bws()
|
||||
except Exception as exc: # noqa: BLE001 — never block startup
|
||||
logger.warning("bws auto-install failed: %s", exc)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _platform_binary_name() -> str:
|
||||
return "bws.exe" if platform.system() == "Windows" else "bws"
|
||||
|
||||
|
||||
def _platform_asset_name() -> str:
|
||||
"""Map (uname, arch, libc) → the upstream asset filename.
|
||||
|
||||
Asset names follow Rust's target triple convention. Linux defaults
|
||||
to gnu (glibc); we switch to musl only if ldd --version says so.
|
||||
"""
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "Darwin":
|
||||
# Universal binary works on both Intel and Apple Silicon — no
|
||||
# need to pick a per-arch asset.
|
||||
return f"bws-macos-universal-{_BWS_VERSION}.zip"
|
||||
|
||||
if system == "Windows":
|
||||
arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
|
||||
return f"bws-{arch}-pc-windows-msvc-{_BWS_VERSION}.zip"
|
||||
|
||||
if system == "Linux":
|
||||
arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
|
||||
libc = "gnu"
|
||||
# ldd --version writes to stderr on glibc, stdout on musl. We
|
||||
# don't need bullet-proof detection — getting it wrong falls
|
||||
# back to a clear error from the binary loader, which we catch.
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ldd", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if "musl" in (res.stdout + res.stderr).lower():
|
||||
libc = "musl"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return f"bws-{arch}-unknown-linux-{libc}-{_BWS_VERSION}.zip"
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unsupported platform for bws auto-install: {system} {machine}"
|
||||
)
|
||||
|
||||
|
||||
def install_bws(*, force: bool = False) -> Path:
|
||||
"""Download, verify, and install the pinned ``bws`` binary.
|
||||
|
||||
Returns the path to the installed executable. Raises on any
|
||||
failure (network, checksum, extraction) — callers in the auto-install
|
||||
path catch these; the user-facing ``hermes secrets bitwarden setup``
|
||||
surface lets them propagate so the wizard can show a clear error.
|
||||
"""
|
||||
bin_dir = _hermes_bin_dir()
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = bin_dir / _platform_binary_name()
|
||||
|
||||
if target.exists() and not force:
|
||||
return target
|
||||
|
||||
asset_name = _platform_asset_name()
|
||||
asset_url = f"{_BWS_RELEASE_BASE}/{asset_name}"
|
||||
checksum_url = f"{_BWS_RELEASE_BASE}/{_BWS_CHECKSUM_NAME}"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-bws-") as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
zip_path = tmp / asset_name
|
||||
checksum_path = tmp / _BWS_CHECKSUM_NAME
|
||||
|
||||
logger.info("Downloading %s", asset_url)
|
||||
_http_download(asset_url, zip_path)
|
||||
_http_download(checksum_url, checksum_path)
|
||||
|
||||
expected = _expected_sha256(checksum_path, asset_name)
|
||||
actual = _sha256_file(zip_path)
|
||||
if expected.lower() != actual.lower():
|
||||
raise RuntimeError(
|
||||
f"Checksum mismatch for {asset_name}: "
|
||||
f"expected {expected}, got {actual}"
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
member = _pick_zip_member(zf, _platform_binary_name())
|
||||
zf.extract(member, tmp)
|
||||
extracted = tmp / member
|
||||
|
||||
# Move into place atomically. We write to a sibling tempfile in
|
||||
# the final directory so the rename can't cross filesystems.
|
||||
fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".bws_")
|
||||
os.close(fd)
|
||||
shutil.copy2(extracted, staged)
|
||||
os.chmod(
|
||||
staged,
|
||||
stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR
|
||||
| stat.S_IRGRP | stat.S_IXGRP
|
||||
| stat.S_IROTH | stat.S_IXOTH,
|
||||
)
|
||||
os.replace(staged, target)
|
||||
|
||||
logger.info("Installed bws %s at %s", _BWS_VERSION, target)
|
||||
return target
|
||||
|
||||
|
||||
def _http_download(url: str, dest: Path) -> None:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_BWS_DOWNLOAD_TIMEOUT) as resp: # noqa: S310
|
||||
with open(dest, "wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"Failed to download {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _expected_sha256(checksum_file: Path, asset_name: str) -> str:
|
||||
"""Parse the upstream ``bws-sha256-checksums-X.Y.Z.txt`` file.
|
||||
|
||||
Format is the standard ``sha256sum`` output: ``<hex> <filename>``,
|
||||
one per line.
|
||||
"""
|
||||
text = checksum_file.read_text(encoding="utf-8", errors="replace")
|
||||
for line in text.splitlines():
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2 and parts[-1] == asset_name:
|
||||
return parts[0]
|
||||
raise RuntimeError(
|
||||
f"No checksum entry for {asset_name} in {checksum_file.name}"
|
||||
)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str:
|
||||
"""Find the binary inside the upstream zip.
|
||||
|
||||
Historically the archive has been flat (``bws`` at the root) but we
|
||||
tolerate a top-level directory just in case upstream changes.
|
||||
"""
|
||||
candidates = [n for n in zf.namelist() if n.split("/")[-1] == binary_name]
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
f"Could not find {binary_name} inside downloaded archive "
|
||||
f"(members: {zf.namelist()[:5]}...)"
|
||||
)
|
||||
# Prefer the shortest path (i.e. root over nested) for determinism.
|
||||
candidates.sort(key=len)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret fetch + apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _token_fingerprint(token: str) -> str:
|
||||
"""SHA-256 prefix used as a cache key — never logged, never displayed."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def fetch_bitwarden_secrets(
|
||||
*,
|
||||
access_token: str,
|
||||
project_id: str,
|
||||
binary: Optional[Path] = None,
|
||||
cache_ttl_seconds: float = 300,
|
||||
use_cache: bool = True,
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
"""Pull the secrets for ``project_id`` from Bitwarden Secrets Manager.
|
||||
|
||||
Returns ``(secrets_dict, warnings_list)``.
|
||||
|
||||
Raises :class:`RuntimeError` for fatal conditions (missing binary,
|
||||
auth failure, unparseable output). Callers in the env_loader path
|
||||
catch this and emit a single warning; callers in the user-facing
|
||||
setup wizard let it propagate.
|
||||
"""
|
||||
if not access_token:
|
||||
raise RuntimeError("Bitwarden access token is empty")
|
||||
if not project_id:
|
||||
raise RuntimeError("Bitwarden project_id is empty")
|
||||
|
||||
cache_key = (_token_fingerprint(access_token), project_id)
|
||||
if use_cache:
|
||||
cached = _CACHE.get(cache_key)
|
||||
if cached and cached.is_fresh(cache_ttl_seconds):
|
||||
return cached.secrets, []
|
||||
|
||||
bws = binary or find_bws(install_if_missing=True)
|
||||
if bws is None:
|
||||
raise RuntimeError(
|
||||
"bws binary not available — auto-install failed and `bws` is "
|
||||
"not on PATH. Install manually from "
|
||||
"https://github.com/bitwarden/sdk-sm/releases or re-run "
|
||||
"`hermes secrets bitwarden setup`."
|
||||
)
|
||||
|
||||
secrets, warnings = _run_bws_list(bws, access_token, project_id)
|
||||
_CACHE[cache_key] = _CachedFetch(secrets=secrets, fetched_at=time.time())
|
||||
return secrets, warnings
|
||||
|
||||
|
||||
def _run_bws_list(
|
||||
bws: Path, access_token: str, project_id: str
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
cmd = [str(bws), "secret", "list", project_id, "--output", "json"]
|
||||
env = os.environ.copy()
|
||||
env["BWS_ACCESS_TOKEN"] = access_token
|
||||
# Make sure we're not echoing telemetry / colour codes into json.
|
||||
env.setdefault("NO_COLOR", "1")
|
||||
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603 — bws path is trusted
|
||||
cmd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_BWS_RUN_TIMEOUT,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
f"bws timed out after {_BWS_RUN_TIMEOUT}s fetching secrets"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to invoke bws: {exc}") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
# bws writes auth/network errors to stderr in plain English.
|
||||
# Strip ANSI just in case and surface the first 200 chars.
|
||||
err = (proc.stderr or proc.stdout or "").strip().replace("\x1b", "")
|
||||
raise RuntimeError(
|
||||
f"bws exited {proc.returncode}: {err[:200]}"
|
||||
)
|
||||
|
||||
raw = proc.stdout.strip()
|
||||
if not raw:
|
||||
return {}, ["bws returned no output (empty project?)"]
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"bws returned non-JSON output: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise RuntimeError(
|
||||
f"bws returned unexpected shape: {type(payload).__name__}"
|
||||
)
|
||||
|
||||
secrets: Dict[str, str] = {}
|
||||
warnings: List[str] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = item.get("key")
|
||||
value = item.get("value")
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
continue
|
||||
if not _is_valid_env_name(key):
|
||||
warnings.append(
|
||||
f"Skipping secret {key!r}: not a valid env-var name"
|
||||
)
|
||||
continue
|
||||
secrets[key] = value
|
||||
return secrets, warnings
|
||||
|
||||
|
||||
def _is_valid_env_name(name: str) -> bool:
|
||||
if not name:
|
||||
return False
|
||||
if not (name[0].isalpha() or name[0] == "_"):
|
||||
return False
|
||||
return all(c.isalnum() or c == "_" for c in name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — called from hermes_cli.env_loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_bitwarden_secrets(
|
||||
*,
|
||||
enabled: bool,
|
||||
access_token_env: str = "BWS_ACCESS_TOKEN",
|
||||
project_id: str = "",
|
||||
override_existing: bool = False,
|
||||
cache_ttl_seconds: float = 300,
|
||||
auto_install: bool = True,
|
||||
) -> FetchResult:
|
||||
"""Pull secrets from BSM and set them on ``os.environ``.
|
||||
|
||||
This is the function ``load_hermes_dotenv()`` calls after the .env
|
||||
files have loaded. It is intentionally defensive — any failure
|
||||
returns a :class:`FetchResult` with ``error`` set; it never raises.
|
||||
|
||||
Parameters mirror the ``secrets.bitwarden.*`` config keys so the
|
||||
caller can just splat the dict in.
|
||||
"""
|
||||
result = FetchResult()
|
||||
|
||||
if not enabled:
|
||||
return result
|
||||
|
||||
access_token = os.environ.get(access_token_env, "").strip()
|
||||
if not access_token:
|
||||
result.error = (
|
||||
f"secrets.bitwarden.enabled is true but {access_token_env} is "
|
||||
"not set. Run `hermes secrets bitwarden setup`."
|
||||
)
|
||||
return result
|
||||
|
||||
if not project_id:
|
||||
result.error = (
|
||||
"secrets.bitwarden.project_id is empty. "
|
||||
"Run `hermes secrets bitwarden setup`."
|
||||
)
|
||||
return result
|
||||
|
||||
binary = find_bws(install_if_missing=auto_install)
|
||||
result.binary_path = binary
|
||||
if binary is None:
|
||||
result.error = (
|
||||
"bws binary not available and auto-install is disabled. "
|
||||
"Run `hermes secrets bitwarden setup` to install."
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
secrets, warnings = fetch_bitwarden_secrets(
|
||||
access_token=access_token,
|
||||
project_id=project_id,
|
||||
binary=binary,
|
||||
cache_ttl_seconds=cache_ttl_seconds,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
result.error = str(exc)
|
||||
return result
|
||||
|
||||
result.secrets = secrets
|
||||
result.warnings.extend(warnings)
|
||||
|
||||
for key, value in secrets.items():
|
||||
if key == access_token_env:
|
||||
# Don't let BSM clobber the very token we used to fetch
|
||||
# itself — that would be a footgun if someone stored the
|
||||
# token as a BSM secret too.
|
||||
result.skipped.append(key)
|
||||
continue
|
||||
if not override_existing and os.environ.get(key):
|
||||
result.skipped.append(key)
|
||||
continue
|
||||
os.environ[key] = value
|
||||
result.applied.append(key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test hook — used by hermetic tests to flush the cache between cases.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reset_cache_for_tests() -> None:
|
||||
_CACHE.clear()
|
||||
|
|
@ -1747,6 +1747,37 @@ DEFAULT_CONFIG = {
|
|||
"retries": 2,
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# External secret sources
|
||||
# =========================================================================
|
||||
# Pull credentials from external secret managers at process startup
|
||||
# rather than storing them in ~/.hermes/.env.
|
||||
"secrets": {
|
||||
"bitwarden": {
|
||||
# Master switch. When false, BSM is never contacted and the
|
||||
# bws binary is never auto-installed — same as not having
|
||||
# this section at all.
|
||||
"enabled": False,
|
||||
# Name of the env var that holds the Bitwarden machine-account
|
||||
# access token. This is the one bootstrap secret; it lives
|
||||
# in ~/.hermes/.env (or your shell) and never in config.yaml.
|
||||
"access_token_env": "BWS_ACCESS_TOKEN",
|
||||
# UUID of the BSM project to sync from.
|
||||
"project_id": "",
|
||||
# Seconds to cache fetched secrets in-process. 0 disables.
|
||||
"cache_ttl_seconds": 300,
|
||||
# When True, BSM values overwrite existing env vars. Default
|
||||
# True because the point of using BSM is centralized rotation —
|
||||
# if .env had the final say, rotating in Bitwarden wouldn't
|
||||
# take effect until you also cleared the matching .env line.
|
||||
"override_existing": True,
|
||||
# When True, the bws binary is auto-downloaded into
|
||||
# ~/.hermes/bin/ on first use. When False you must install
|
||||
# bws yourself and have it on PATH.
|
||||
"auto_install": True,
|
||||
},
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 23,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,4 +172,81 @@ 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)
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def _apply_external_secret_sources(home_path: Path) -> None:
|
||||
"""Pull secrets from external sources (currently Bitwarden) into env.
|
||||
|
||||
Runs AFTER dotenv loads so .env values are visible (we use them to
|
||||
locate the access token) but BEFORE the rest of Hermes reads
|
||||
``os.environ`` for credentials. Any failure here is logged and
|
||||
swallowed — external secret sources must never block startup.
|
||||
"""
|
||||
try:
|
||||
cfg = _load_secrets_config(home_path)
|
||||
except Exception: # noqa: BLE001 — config errors must not block startup
|
||||
return
|
||||
|
||||
bw_cfg = (cfg or {}).get("bitwarden") or {}
|
||||
if not bw_cfg.get("enabled"):
|
||||
return
|
||||
|
||||
try:
|
||||
from agent.secret_sources.bitwarden import apply_bitwarden_secrets
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
result = apply_bitwarden_secrets(
|
||||
enabled=True,
|
||||
access_token_env=bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"),
|
||||
project_id=bw_cfg.get("project_id", ""),
|
||||
override_existing=bool(bw_cfg.get("override_existing", False)),
|
||||
cache_ttl_seconds=float(bw_cfg.get("cache_ttl_seconds", 300)),
|
||||
auto_install=bool(bw_cfg.get("auto_install", True)),
|
||||
)
|
||||
|
||||
if result.applied:
|
||||
# Re-run the ASCII sanitization pass: BSM values are user-supplied
|
||||
# and might have the same copy-paste corruption as a manually
|
||||
# edited .env (see #6843).
|
||||
_sanitize_loaded_credentials()
|
||||
print(
|
||||
f" Bitwarden Secrets Manager: applied {len(result.applied)} "
|
||||
f"secret{'s' if len(result.applied) != 1 else ''} "
|
||||
f"({', '.join(sorted(result.applied))})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if result.error:
|
||||
print(
|
||||
f" Bitwarden Secrets Manager: {result.error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for warn in result.warnings:
|
||||
print(
|
||||
f" Bitwarden Secrets Manager: {warn}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def _load_secrets_config(home_path: Path) -> dict:
|
||||
"""Read just the ``secrets:`` section out of config.yaml.
|
||||
|
||||
Imported lazily and isolated from the main config loader so a
|
||||
malformed config can't take down dotenv loading entirely.
|
||||
"""
|
||||
config_path = home_path / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return data.get("secrets") or {}
|
||||
|
|
|
|||
|
|
@ -10425,7 +10425,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
|||
"model", "pairing", "plugins", "postinstall", "profile", "proxy",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
"version", "webhook", "whatsapp", "chat",
|
||||
"version", "webhook", "whatsapp", "chat", "secrets",
|
||||
# Help-ish invocations — plugin commands not being listed in
|
||||
# top-level --help is an acceptable trade-off for skipping an
|
||||
# expensive eager import of every bundled plugin module.
|
||||
|
|
@ -10672,6 +10672,42 @@ def main():
|
|||
)
|
||||
fallback_parser.set_defaults(func=cmd_fallback)
|
||||
|
||||
# =========================================================================
|
||||
# secrets command — external secret managers (currently: Bitwarden)
|
||||
# =========================================================================
|
||||
secrets_parser = subparsers.add_parser(
|
||||
"secrets",
|
||||
help="Manage external secret sources (Bitwarden Secrets Manager)",
|
||||
description=(
|
||||
"Pull API keys from an external secret manager at process startup "
|
||||
"instead of storing them in ~/.hermes/.env. Currently supports "
|
||||
"Bitwarden Secrets Manager. See: "
|
||||
"https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden"
|
||||
),
|
||||
)
|
||||
secrets_subparsers = secrets_parser.add_subparsers(dest="secrets_command")
|
||||
|
||||
secrets_bw = secrets_subparsers.add_parser(
|
||||
"bitwarden",
|
||||
aliases=["bw"],
|
||||
help="Bitwarden Secrets Manager integration",
|
||||
)
|
||||
|
||||
# Lazy import — only pays for itself when this subcommand is actually used.
|
||||
from hermes_cli import secrets_cli as _secrets_cli
|
||||
|
||||
_secrets_cli.register_cli(secrets_bw)
|
||||
|
||||
def _dispatch_secrets(args): # noqa: ANN001
|
||||
sub = getattr(args, "secrets_command", None)
|
||||
bw_sub = getattr(args, "secrets_bw_command", None)
|
||||
if sub in ("bitwarden", "bw") and bw_sub is not None:
|
||||
return args.func(args)
|
||||
secrets_parser.print_help()
|
||||
return 0
|
||||
|
||||
secrets_parser.set_defaults(func=_dispatch_secrets)
|
||||
|
||||
# =========================================================================
|
||||
# migrate command
|
||||
# =========================================================================
|
||||
|
|
|
|||
445
hermes_cli/secrets_cli.py
Normal file
445
hermes_cli/secrets_cli.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
"""CLI handlers for ``hermes secrets bitwarden ...``.
|
||||
|
||||
Subcommands:
|
||||
setup — interactive wizard: install bws, prompt for token + project, test fetch
|
||||
status — show current config + binary version + last fetch outcome
|
||||
sync — run a fetch right now and show what would be applied (dry-run friendly)
|
||||
disable — flip ``secrets.bitwarden.enabled`` to False
|
||||
install — just download the bws binary (no token / project required)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from agent.secret_sources import bitwarden as bw
|
||||
from hermes_cli.config import (
|
||||
get_env_path,
|
||||
load_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argparse wiring — called from hermes_cli.main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_cli(parent_parser: argparse.ArgumentParser) -> None:
|
||||
"""Attach the ``bitwarden`` subcommand tree to a parent parser.
|
||||
|
||||
Called from ``hermes_cli.main`` as part of building the top-level
|
||||
``hermes secrets`` parser.
|
||||
"""
|
||||
sub = parent_parser.add_subparsers(dest="secrets_bw_command")
|
||||
|
||||
setup = sub.add_parser(
|
||||
"setup",
|
||||
help="Interactive wizard: install bws, store access token, pick project",
|
||||
)
|
||||
setup.add_argument(
|
||||
"--project-id",
|
||||
help="Pre-select a project UUID instead of prompting",
|
||||
)
|
||||
setup.add_argument(
|
||||
"--access-token",
|
||||
help="Provide the access token non-interactively (will be stored in .env)",
|
||||
)
|
||||
setup.set_defaults(func=cmd_setup)
|
||||
|
||||
status = sub.add_parser("status", help="Show config + binary + last fetch")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
sync = sub.add_parser("sync", help="Fetch secrets now and report what changed")
|
||||
sync.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Actually export the secrets into the current shell's env (default: dry-run)",
|
||||
)
|
||||
sync.set_defaults(func=cmd_sync)
|
||||
|
||||
disable = sub.add_parser("disable", help="Turn off the Bitwarden integration")
|
||||
disable.set_defaults(func=cmd_disable)
|
||||
|
||||
install = sub.add_parser(
|
||||
"install",
|
||||
help=f"Download and verify the pinned bws binary (v{bw._BWS_VERSION})",
|
||||
)
|
||||
install.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-download even if a managed copy already exists",
|
||||
)
|
||||
install.set_defaults(func=cmd_install)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_setup(args: argparse.Namespace) -> int:
|
||||
console = Console()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold]Bitwarden Secrets Manager setup[/bold]\n\n"
|
||||
"Need an access token? In the Bitwarden web app:\n"
|
||||
" Secrets Manager → Machine accounts → [your account] →\n"
|
||||
" Access tokens → Create access token\n\n"
|
||||
"Copy the token (starts with [cyan]0.[/cyan]…) — it cannot be retrieved later.",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ binary
|
||||
console.print()
|
||||
console.print("[bold]Step 1[/bold] Install the bws CLI")
|
||||
try:
|
||||
binary = bw.find_bws(install_if_missing=False)
|
||||
if binary is None:
|
||||
console.print(" No bws on PATH — downloading…")
|
||||
binary = bw.install_bws()
|
||||
version = _bws_version(binary)
|
||||
console.print(f" [green]✓[/green] {binary} ({version})")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f" [red]✗ Could not install bws: {exc}[/red]")
|
||||
console.print(
|
||||
" Manual install: "
|
||||
"https://github.com/bitwarden/sdk-sm/releases"
|
||||
)
|
||||
return 1
|
||||
|
||||
# ------------------------------------------------------------------- token
|
||||
console.print()
|
||||
console.print("[bold]Step 2[/bold] Provide your access token")
|
||||
cfg = load_config()
|
||||
secrets_cfg = (cfg.setdefault("secrets", {})
|
||||
.setdefault("bitwarden", {}))
|
||||
token_env = secrets_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
|
||||
token = (args.access_token or "").strip()
|
||||
if not token:
|
||||
token = getpass.getpass(f" Paste access token ({token_env}): ").strip()
|
||||
if not token:
|
||||
console.print(" [red]Empty token, aborting.[/red]")
|
||||
return 1
|
||||
if not token.startswith("0."):
|
||||
console.print(
|
||||
" [yellow]Warning: token doesn't start with '0.' — usually that means "
|
||||
"you pasted something other than a BSM access token. Continuing anyway.[/yellow]"
|
||||
)
|
||||
|
||||
save_env_value(token_env, token)
|
||||
os.environ[token_env] = token # so the test fetch below sees it
|
||||
console.print(f" [green]✓[/green] stored in {get_env_path()} as {token_env}")
|
||||
|
||||
# ------------------------------------------------------------------- project
|
||||
if args.project_id and args.project_id.strip():
|
||||
project_id = args.project_id.strip()
|
||||
else:
|
||||
console.print()
|
||||
console.print("[bold]Step 3[/bold] Pick a project")
|
||||
project_id = ""
|
||||
projects = _list_projects(binary, token, console)
|
||||
if projects is None:
|
||||
return 1
|
||||
if not projects:
|
||||
console.print(" [yellow]No projects visible to this machine account.[/yellow]")
|
||||
console.print(
|
||||
" In the Bitwarden web app, open the machine account → Projects tab "
|
||||
"and grant it access to at least one project."
|
||||
)
|
||||
return 1
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("#", style="cyan", width=4)
|
||||
table.add_column("Name")
|
||||
table.add_column("ID", style="dim")
|
||||
for i, p in enumerate(projects, 1):
|
||||
table.add_row(str(i), p.get("name", "?"), p.get("id", "?"))
|
||||
console.print(table)
|
||||
|
||||
while True:
|
||||
choice = console.input(f" Select project [1-{len(projects)}]: ").strip()
|
||||
if not choice:
|
||||
continue
|
||||
try:
|
||||
idx = int(choice)
|
||||
except ValueError:
|
||||
console.print(" [red]Enter a number.[/red]")
|
||||
continue
|
||||
if 1 <= idx <= len(projects):
|
||||
project_id = projects[idx - 1]["id"]
|
||||
break
|
||||
console.print(f" [red]Out of range — pick 1-{len(projects)}.[/red]")
|
||||
|
||||
# ------------------------------------------------------------------- test
|
||||
console.print()
|
||||
step_num = 4 if not (args.project_id and args.project_id.strip()) else 3
|
||||
console.print(f"[bold]Step {step_num}[/bold] Test fetch")
|
||||
try:
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token=token,
|
||||
project_id=project_id,
|
||||
binary=binary,
|
||||
use_cache=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f" [red]✗ Fetch failed: {exc}[/red]")
|
||||
return 1
|
||||
|
||||
if not secrets:
|
||||
console.print(" [yellow]Fetch succeeded but the project has no secrets.[/yellow]")
|
||||
else:
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Status")
|
||||
for key in sorted(secrets):
|
||||
if key == token_env:
|
||||
status = "[dim]bootstrap token — never overrides itself[/dim]"
|
||||
elif os.environ.get(key):
|
||||
status = "[yellow]already set in env (will be overwritten)[/yellow]"
|
||||
else:
|
||||
status = "[green]new[/green]"
|
||||
table.add_row(key, status)
|
||||
console.print(table)
|
||||
for w in warnings:
|
||||
console.print(f" [yellow]warning:[/yellow] {w}")
|
||||
|
||||
# ------------------------------------------------------------------- save
|
||||
secrets_cfg["enabled"] = True
|
||||
secrets_cfg["project_id"] = project_id
|
||||
secrets_cfg.setdefault("access_token_env", token_env)
|
||||
secrets_cfg.setdefault("cache_ttl_seconds", 300)
|
||||
secrets_cfg.setdefault("override_existing", True)
|
||||
secrets_cfg.setdefault("auto_install", True)
|
||||
save_config(cfg)
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
"[green]✓ Bitwarden Secrets Manager is enabled.[/green] "
|
||||
"Secrets will be pulled at the start of every Hermes process."
|
||||
)
|
||||
console.print(
|
||||
" Status: [cyan]hermes secrets bitwarden status[/cyan]\n"
|
||||
" Refresh: [cyan]hermes secrets bitwarden sync[/cyan]\n"
|
||||
" Disable: [cyan]hermes secrets bitwarden disable[/cyan]"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
console = Console()
|
||||
cfg = load_config()
|
||||
bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {}
|
||||
|
||||
enabled = bool(bw_cfg.get("enabled"))
|
||||
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
project_id = bw_cfg.get("project_id", "")
|
||||
token_set = bool(os.environ.get(token_env))
|
||||
|
||||
table = Table(show_header=False, box=None, padding=(0, 2))
|
||||
table.add_column("", style="bold")
|
||||
table.add_column("")
|
||||
table.add_row("Enabled", _yn(enabled))
|
||||
table.add_row("Token env var", token_env)
|
||||
table.add_row("Token in env", _yn(token_set))
|
||||
table.add_row("Project ID", project_id or "[dim](unset)[/dim]")
|
||||
table.add_row("Override existing", _yn(bool(bw_cfg.get("override_existing", False))))
|
||||
table.add_row("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300)))
|
||||
table.add_row("Auto-install", _yn(bool(bw_cfg.get("auto_install", True))))
|
||||
|
||||
binary = bw.find_bws(install_if_missing=False)
|
||||
if binary:
|
||||
table.add_row("bws binary", f"{binary} ({_bws_version(binary)})")
|
||||
else:
|
||||
table.add_row("bws binary", "[yellow]not installed[/yellow]")
|
||||
|
||||
console.print(Panel(table, title="Bitwarden Secrets Manager", border_style="cyan"))
|
||||
|
||||
if not enabled:
|
||||
console.print("\n Run [cyan]hermes secrets bitwarden setup[/cyan] to enable.")
|
||||
return 0
|
||||
if not token_set:
|
||||
console.print(
|
||||
f"\n [yellow]Enabled but {token_env} is not set — Hermes will skip BSM "
|
||||
"and warn on next startup.[/yellow]"
|
||||
)
|
||||
if not project_id:
|
||||
console.print(
|
||||
"\n [yellow]Enabled but no project_id — nothing to fetch.[/yellow]"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> int:
|
||||
console = Console()
|
||||
cfg = load_config()
|
||||
bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {}
|
||||
if not bw_cfg.get("enabled"):
|
||||
console.print(
|
||||
"[yellow]Bitwarden integration is disabled. Run "
|
||||
"`hermes secrets bitwarden setup` first.[/yellow]"
|
||||
)
|
||||
return 1
|
||||
|
||||
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
token = os.environ.get(token_env, "").strip()
|
||||
if not token:
|
||||
console.print(f"[red]{token_env} is not set.[/red]")
|
||||
return 1
|
||||
|
||||
project_id = bw_cfg.get("project_id", "")
|
||||
if not project_id:
|
||||
console.print("[red]No project_id configured.[/red]")
|
||||
return 1
|
||||
|
||||
try:
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token=token,
|
||||
project_id=project_id,
|
||||
use_cache=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f"[red]Fetch failed: {exc}[/red]")
|
||||
return 1
|
||||
|
||||
if not secrets:
|
||||
console.print("[yellow]No secrets in project.[/yellow]")
|
||||
return 0
|
||||
|
||||
override = bool(bw_cfg.get("override_existing", False)) or args.apply
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Action")
|
||||
applied = 0
|
||||
for key in sorted(secrets):
|
||||
if key == token_env:
|
||||
table.add_row(key, "[dim]skip (bootstrap token)[/dim]")
|
||||
continue
|
||||
already = bool(os.environ.get(key))
|
||||
if already and not override:
|
||||
table.add_row(key, "[dim]skip (already set)[/dim]")
|
||||
continue
|
||||
if args.apply:
|
||||
os.environ[key] = secrets[key]
|
||||
applied += 1
|
||||
table.add_row(key, "[green]exported[/green]" + (" (overrode)" if already else ""))
|
||||
else:
|
||||
table.add_row(key, "[green]would export[/green]" + (" (overrides)" if already else ""))
|
||||
|
||||
console.print(table)
|
||||
for w in warnings:
|
||||
console.print(f"[yellow]warning:[/yellow] {w}")
|
||||
|
||||
if not args.apply:
|
||||
console.print(
|
||||
"\n This was a dry-run — secrets are picked up automatically on the "
|
||||
"next [cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] "
|
||||
"to export into the current shell instead."
|
||||
)
|
||||
else:
|
||||
console.print(f"\n [green]Exported {applied} secret(s) into current process.[/green]")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_disable(args: argparse.Namespace) -> int:
|
||||
console = Console()
|
||||
cfg = load_config()
|
||||
bw_cfg = (cfg.setdefault("secrets", {})
|
||||
.setdefault("bitwarden", {}))
|
||||
bw_cfg["enabled"] = False
|
||||
save_config(cfg)
|
||||
console.print(
|
||||
"[green]Disabled.[/green] Bitwarden secrets will NOT be pulled on the next "
|
||||
"Hermes invocation.\n"
|
||||
" Your access token is left in .env — remove it manually if you also want "
|
||||
"to revoke the credential."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> int:
|
||||
console = Console()
|
||||
try:
|
||||
path = bw.install_bws(force=bool(args.force))
|
||||
console.print(f"[green]✓[/green] {path} ({_bws_version(path)})")
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f"[red]Install failed: {exc}[/red]")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _yn(b: bool) -> str:
|
||||
return "[green]yes[/green]" if b else "[dim]no[/dim]"
|
||||
|
||||
|
||||
def _bws_version(binary: Path) -> str:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(binary), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
return (res.stdout or res.stderr).strip().splitlines()[0]
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return "version unknown"
|
||||
|
||||
|
||||
def _list_projects(
|
||||
binary: Path, token: str, console: Console
|
||||
) -> Optional[List[dict]]:
|
||||
"""Call ``bws project list`` and return the parsed list, or None on failure."""
|
||||
env = os.environ.copy()
|
||||
env["BWS_ACCESS_TOKEN"] = token
|
||||
env.setdefault("NO_COLOR", "1")
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(binary), "project", "list", "--output", "json"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
console.print(f" [red]Couldn't list projects: {exc}[/red]")
|
||||
return None
|
||||
|
||||
if res.returncode != 0:
|
||||
err = (res.stderr or res.stdout).strip()[:300]
|
||||
console.print(f" [red]bws project list failed: {err}[/red]")
|
||||
if "authorization" in err.lower() or "invalid" in err.lower():
|
||||
console.print(
|
||||
" [yellow]This usually means the access token is wrong or revoked. "
|
||||
"Double-check it in the Bitwarden web app.[/yellow]"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(res.stdout or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
console.print(f" [red]bws returned non-JSON: {exc}[/red]")
|
||||
return None
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [p for p in data if isinstance(p, dict) and p.get("id")]
|
||||
BIN
infographic/bitwarden-secrets-manager/infographic.png
Normal file
BIN
infographic/bitwarden-secrets-manager/infographic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
121
infographic/bitwarden-secrets-manager/prompts/infographic.md
Normal file
121
infographic/bitwarden-secrets-manager/prompts/infographic.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
Create a professional infographic following these specifications:
|
||||
|
||||
## Image Specifications
|
||||
|
||||
- **Type**: Infographic
|
||||
- **Layout**: bento-grid
|
||||
- **Style**: retro-pop-grid
|
||||
- **Aspect Ratio**: 1:1 (square)
|
||||
- **Language**: en
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Follow the layout structure precisely for information architecture
|
||||
- Apply style aesthetics consistently throughout
|
||||
- Keep information concise, highlight keywords and core concepts
|
||||
- Use ample whitespace for visual clarity
|
||||
- Maintain clear visual hierarchy
|
||||
|
||||
## Text Requirements
|
||||
|
||||
- All text must match the specified style treatment
|
||||
- Main titles should be prominent and readable
|
||||
- Key concepts should be visually emphasized
|
||||
- Labels should be clear and appropriately sized
|
||||
- Use English for all text content
|
||||
|
||||
## Layout Guidelines (bento-grid)
|
||||
|
||||
- Grid of rectangular cells with varied sizes (1x1, 2x1, 1x2, 2x2)
|
||||
- Hero cell ("ONE TOKEN, EVERY KEY") takes the largest position (top-center or upper-left, 2x2)
|
||||
- Supporting cells around the hero, mixed cell sizes for rhythm
|
||||
- Each cell self-contained with its own title + icon + brief content
|
||||
- Title strip at the top: "BITWARDEN SECRETS MANAGER — HERMES-AGENT PR #30035"
|
||||
- Footer strip at the bottom with commit SHA + repo
|
||||
|
||||
## Style Guidelines (retro-pop-grid)
|
||||
|
||||
- 1970s retro pop art with strict Swiss international grid
|
||||
- Background: warm vintage cream/beige (#F5F0E6)
|
||||
- Accents: salmon pink, sky blue, mustard yellow, mint green — all muted retro tones
|
||||
- Pure solid black (#000000) and solid white (#FFFFFF) for extreme-contrast cells
|
||||
- Uniform thick black outlines on ALL illustrations, text boxes, grid dividers
|
||||
- Pure 2D flat vector aesthetic with subtle screen-print texture
|
||||
- One cell inverted to black-background-with-white-text for the "NEVER BLOCKS STARTUP" warning section
|
||||
- Geometric fill patterns in empty cells: checkerboards, diagonal lines, dot grids
|
||||
- Flat abstract symbols: shields (security), wrenches (install), arrows (rotation), keyholes (auth), checkmarks (tests)
|
||||
- Vintage comic-style smiley face for "26/26 PASSING" cell
|
||||
- Bold brutalist or thick retro display fonts for headers; clean sans-serif body
|
||||
- Decorative stylistic labels acceptable: "WARNING", "NEW DEFAULT", "PINNED", "VERIFIED", "ROTATE"
|
||||
|
||||
## Avoid
|
||||
|
||||
- 3D rendering, gradients, soft shadows, sketch-like lines
|
||||
- Free-floating elements — everything anchored in grid cells
|
||||
- Pure white background — must use warm cream/beige
|
||||
|
||||
---
|
||||
|
||||
Generate the infographic based on the content below:
|
||||
|
||||
### Title (top strip)
|
||||
BITWARDEN SECRETS MANAGER → HERMES-AGENT
|
||||
PR #30035
|
||||
|
||||
### HERO CELL (largest, top-center, salmon pink background with thick black border)
|
||||
ONE TOKEN, EVERY KEY
|
||||
Rotate once in the Bitwarden web app.
|
||||
Every Hermes process picks it up on next start.
|
||||
NEW DEFAULT: override_existing = true
|
||||
|
||||
### Cell — LAZY INSTALL (sky blue background)
|
||||
~/.hermes/bin/bws
|
||||
bws v2.0.0 PINNED
|
||||
SHA-256 VERIFIED
|
||||
No apt · no brew · no sudo
|
||||
Icon: wrench + downward arrow
|
||||
|
||||
### Cell — CLI SURFACE (mustard yellow background, checkerboard accents)
|
||||
$ hermes secrets bitwarden
|
||||
setup wizard
|
||||
status diagnose
|
||||
sync fetch
|
||||
install binary
|
||||
disable off
|
||||
Icon: terminal prompt symbol
|
||||
|
||||
### Cell — SOURCE OF TRUTH (mint green background)
|
||||
BITWARDEN WINS
|
||||
Overwrites stale .env on every start
|
||||
Bootstrap token never overwritten (exception)
|
||||
Icon: keyhole + arrow
|
||||
|
||||
### Cell — INVERTED BLACK CELL with WHITE TEXT — NEVER BLOCKS STARTUP (extreme contrast)
|
||||
WARNING-FREE STARTUP
|
||||
Missing binary → warn + continue
|
||||
Bad token → warn + continue
|
||||
Network down → warn + continue
|
||||
Checksum mismatch → refuse + warn
|
||||
30s timeout ceiling
|
||||
Icon: white triangle warning sign
|
||||
|
||||
### Cell — TESTS (cream with thick black outline, vintage comic smiley face)
|
||||
26 / 26
|
||||
HERMETIC
|
||||
subprocess + urllib mocked
|
||||
linux · macos · windows
|
||||
x86_64 · arm64
|
||||
Icon: comic-style smiley face with checkmark
|
||||
|
||||
### Cell — CONFIG YAML (white background with black grid)
|
||||
secrets:
|
||||
bitwarden:
|
||||
enabled: true
|
||||
project_id: ...
|
||||
override_existing: true
|
||||
cache_ttl_seconds: 300
|
||||
auto_install: true
|
||||
|
||||
### Footer strip (bottom, black-on-cream)
|
||||
PR #30035 · commit 7f9b05668 · NousResearch/hermes-agent
|
||||
10 files · +1743 / -1 · agent/secret_sources/ · hermes_cli/secrets_cli.py
|
||||
57
infographic/bitwarden-secrets-manager/structured-content.md
Normal file
57
infographic/bitwarden-secrets-manager/structured-content.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Hermes-Agent PR #30035 — Bitwarden Secrets Manager Integration
|
||||
|
||||
## Hero
|
||||
**ONE TOKEN, EVERY KEY**
|
||||
Rotate once. Every Hermes process picks it up on next start.
|
||||
`secrets.bitwarden.override_existing: true` (default)
|
||||
|
||||
## Cells
|
||||
|
||||
### Lazy Install
|
||||
- `bws v2.0.0` pinned
|
||||
- Downloaded into `~/.hermes/bin/bws`
|
||||
- SHA-256 verified vs GitHub Releases checksum file
|
||||
- No apt, no brew, no sudo
|
||||
- Cross-platform: linux gnu+musl, macos universal, windows x86_64+arm64
|
||||
|
||||
### CLI Surface
|
||||
- `hermes secrets bitwarden setup` wizard
|
||||
- `hermes secrets bitwarden status` diagnose
|
||||
- `hermes secrets bitwarden sync` dry-run / --apply
|
||||
- `hermes secrets bitwarden install` binary only
|
||||
- `hermes secrets bitwarden disable` off switch
|
||||
|
||||
### Source of Truth
|
||||
- Bitwarden WINS on every Hermes start
|
||||
- BSM values overwrite stale `.env` lines
|
||||
- Rotate a key once → all your machines reload it
|
||||
- Bootstrap token `BWS_ACCESS_TOKEN` is the lone exception (never overwritten)
|
||||
|
||||
### Never Blocks Startup
|
||||
- Missing binary → warn + continue
|
||||
- Bad token → warn + continue
|
||||
- Checksum mismatch → refuse install + warn
|
||||
- No network → warn + continue
|
||||
- Timeout → 30s ceiling, warn + continue
|
||||
|
||||
### Tests
|
||||
- 26/26 passing, hermetic
|
||||
- subprocess + urllib mocked
|
||||
- Platform matrix tested (linux, macos, windows × x86_64, arm64)
|
||||
- Cache hit/miss, auth fail, non-JSON, timeout, override behavior
|
||||
|
||||
### Config
|
||||
```yaml
|
||||
secrets:
|
||||
bitwarden:
|
||||
enabled: true
|
||||
project_id: <uuid>
|
||||
override_existing: true # NEW DEFAULT
|
||||
cache_ttl_seconds: 300
|
||||
auto_install: true
|
||||
```
|
||||
|
||||
## Footer
|
||||
PR #30035 · commit 7f9b05668 · NousResearch/hermes-agent
|
||||
|
||||
10 files changed · +1743 / -1 · agent/secret_sources/ · hermes_cli/secrets_cli.py · tests · docs
|
||||
491
tests/test_bitwarden_secrets.py
Normal file
491
tests/test_bitwarden_secrets.py
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
"""Hermetic tests for the Bitwarden Secrets Manager integration.
|
||||
|
||||
We never hit GitHub or Bitwarden in tests — subprocess + urllib are
|
||||
mocked so the suite stays fast and offline-safe. The "live" pull and
|
||||
binary download are exercised manually by `hermes secrets bitwarden
|
||||
setup` outside of pytest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
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[1]
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _platform_asset_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"system,machine,libc_text,expected",
|
||||
[
|
||||
("Darwin", "x86_64", "",
|
||||
f"bws-macos-universal-{bw._BWS_VERSION}.zip"),
|
||||
("Darwin", "arm64", "",
|
||||
f"bws-macos-universal-{bw._BWS_VERSION}.zip"),
|
||||
("Linux", "x86_64", "glibc",
|
||||
f"bws-x86_64-unknown-linux-gnu-{bw._BWS_VERSION}.zip"),
|
||||
("Linux", "x86_64", "musl libc",
|
||||
f"bws-x86_64-unknown-linux-musl-{bw._BWS_VERSION}.zip"),
|
||||
("Linux", "aarch64", "",
|
||||
f"bws-aarch64-unknown-linux-gnu-{bw._BWS_VERSION}.zip"),
|
||||
("Windows", "AMD64", "",
|
||||
f"bws-x86_64-pc-windows-msvc-{bw._BWS_VERSION}.zip"),
|
||||
("Windows", "ARM64", "",
|
||||
f"bws-aarch64-pc-windows-msvc-{bw._BWS_VERSION}.zip"),
|
||||
],
|
||||
)
|
||||
def test_platform_asset_name(system, machine, libc_text, expected):
|
||||
with mock.patch.object(bw.platform, "system", return_value=system), \
|
||||
mock.patch.object(bw.platform, "machine", return_value=machine), \
|
||||
mock.patch.object(
|
||||
bw.subprocess,
|
||||
"run",
|
||||
return_value=mock.Mock(stdout=libc_text, stderr=libc_text),
|
||||
):
|
||||
assert bw._platform_asset_name() == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install_bws — fully mocked HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_fake_zip(binary_bytes: bytes) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bws", binary_bytes)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_install_bws_happy_path(hermes_home, monkeypatch):
|
||||
fake_binary = b"#!/bin/sh\necho 'bws fake 2.0.0'\n"
|
||||
zip_bytes = _make_fake_zip(fake_binary)
|
||||
asset_name = bw._platform_asset_name()
|
||||
checksum_text = (
|
||||
f"{hashlib.sha256(zip_bytes).hexdigest()} {asset_name}\n"
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff other-file\n"
|
||||
)
|
||||
|
||||
def fake_download(url, dest):
|
||||
if url.endswith(".zip"):
|
||||
Path(dest).write_bytes(zip_bytes)
|
||||
elif url.endswith(".txt"):
|
||||
Path(dest).write_text(checksum_text)
|
||||
else:
|
||||
raise AssertionError(f"unexpected download url: {url}")
|
||||
|
||||
monkeypatch.setattr(bw, "_http_download", fake_download)
|
||||
|
||||
path = bw.install_bws()
|
||||
assert path.exists()
|
||||
assert path.read_bytes() == fake_binary
|
||||
# Executable bit set
|
||||
assert path.stat().st_mode & stat.S_IXUSR
|
||||
|
||||
|
||||
def test_install_bws_checksum_mismatch(hermes_home, monkeypatch):
|
||||
zip_bytes = _make_fake_zip(b"contents")
|
||||
asset_name = bw._platform_asset_name()
|
||||
wrong_checksum = "0" * 64
|
||||
checksum_text = f"{wrong_checksum} {asset_name}\n"
|
||||
|
||||
def fake_download(url, dest):
|
||||
if url.endswith(".zip"):
|
||||
Path(dest).write_bytes(zip_bytes)
|
||||
else:
|
||||
Path(dest).write_text(checksum_text)
|
||||
|
||||
monkeypatch.setattr(bw, "_http_download", fake_download)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Checksum mismatch"):
|
||||
bw.install_bws()
|
||||
|
||||
|
||||
def test_install_bws_missing_checksum_entry(hermes_home, monkeypatch):
|
||||
zip_bytes = _make_fake_zip(b"x")
|
||||
|
||||
def fake_download(url, dest):
|
||||
if url.endswith(".zip"):
|
||||
Path(dest).write_bytes(zip_bytes)
|
||||
else:
|
||||
Path(dest).write_text("ffffffff some-other-file.zip\n")
|
||||
|
||||
monkeypatch.setattr(bw, "_http_download", fake_download)
|
||||
|
||||
with pytest.raises(RuntimeError, match="No checksum entry"):
|
||||
bw.install_bws()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_bitwarden_secrets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_bws_payload(items):
|
||||
return json.dumps(items)
|
||||
|
||||
|
||||
def test_fetch_happy_path(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([
|
||||
{"key": "OPENAI_API_KEY", "value": "sk-abc"},
|
||||
{"key": "ANTHROPIC_API_KEY", "value": "sk-ant-xyz"},
|
||||
])
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
assert cmd[0] == str(fake_binary)
|
||||
assert "secret" in cmd and "list" in cmd
|
||||
assert kwargs["env"]["BWS_ACCESS_TOKEN"] == "0.fake.token"
|
||||
return mock.Mock(returncode=0, stdout=payload, stderr="")
|
||||
|
||||
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
||||
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token="0.fake.token",
|
||||
project_id="proj-uuid",
|
||||
binary=fake_binary,
|
||||
use_cache=False,
|
||||
)
|
||||
assert secrets == {
|
||||
"OPENAI_API_KEY": "sk-abc",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-xyz",
|
||||
}
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_fetch_skips_invalid_env_names(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([
|
||||
{"key": "VALID_KEY", "value": "v1"},
|
||||
{"key": "1BAD_START", "value": "v2"},
|
||||
{"key": "has spaces", "value": "v3"},
|
||||
{"key": "DASH-KEY", "value": "v4"},
|
||||
])
|
||||
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
|
||||
)
|
||||
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token="0.t",
|
||||
project_id="p",
|
||||
binary=fake_binary,
|
||||
use_cache=False,
|
||||
)
|
||||
assert secrets == {"VALID_KEY": "v1"}
|
||||
assert len(warnings) == 3
|
||||
|
||||
|
||||
def test_fetch_auth_failure(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: mock.Mock(
|
||||
returncode=1, stdout="", stderr="Error: invalid access token"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="invalid access token"):
|
||||
bw.fetch_bitwarden_secrets(
|
||||
access_token="0.bad",
|
||||
project_id="p",
|
||||
binary=fake_binary,
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_timeout(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
|
||||
def fake_run(*a, **kw):
|
||||
raise subprocess.TimeoutExpired(cmd="bws", timeout=30)
|
||||
|
||||
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError, match="timed out"):
|
||||
bw.fetch_bitwarden_secrets(
|
||||
access_token="0.t",
|
||||
project_id="p",
|
||||
binary=fake_binary,
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_non_json(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: mock.Mock(
|
||||
returncode=0, stdout="not json at all", stderr=""
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="non-JSON"):
|
||||
bw.fetch_bitwarden_secrets(
|
||||
access_token="0.t",
|
||||
project_id="p",
|
||||
binary=fake_binary,
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_cache_hits(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([{"key": "K", "value": "v"}])
|
||||
|
||||
call_count = {"n": 0}
|
||||
def fake_run(*a, **kw):
|
||||
call_count["n"] += 1
|
||||
return mock.Mock(returncode=0, stdout=payload, stderr="")
|
||||
|
||||
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
||||
|
||||
bw.fetch_bitwarden_secrets(access_token="0.t", project_id="p",
|
||||
binary=fake_binary, cache_ttl_seconds=60)
|
||||
bw.fetch_bitwarden_secrets(access_token="0.t", project_id="p",
|
||||
binary=fake_binary, cache_ttl_seconds=60)
|
||||
assert call_count["n"] == 1 # cached on second call
|
||||
|
||||
|
||||
def test_fetch_cache_disabled(monkeypatch, tmp_path):
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([])
|
||||
call_count = {"n": 0}
|
||||
def fake_run(*a, **kw):
|
||||
call_count["n"] += 1
|
||||
return mock.Mock(returncode=0, stdout=payload, stderr="")
|
||||
monkeypatch.setattr(bw.subprocess, "run", fake_run)
|
||||
|
||||
bw.fetch_bitwarden_secrets(access_token="0.t", project_id="p",
|
||||
binary=fake_binary, use_cache=False)
|
||||
bw.fetch_bitwarden_secrets(access_token="0.t", project_id="p",
|
||||
binary=fake_binary, use_cache=False)
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_bitwarden_secrets — the public entry point used by env_loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_disabled_returns_empty():
|
||||
result = bw.apply_bitwarden_secrets(enabled=False, project_id="p")
|
||||
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(
|
||||
enabled=True, project_id="p", auto_install=False
|
||||
)
|
||||
assert not result.ok
|
||||
assert "BWS_ACCESS_TOKEN" in result.error
|
||||
|
||||
|
||||
def test_apply_missing_project_id(monkeypatch):
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
|
||||
result = bw.apply_bitwarden_secrets(
|
||||
enabled=True, project_id="", auto_install=False
|
||||
)
|
||||
assert not result.ok
|
||||
assert "project_id" in result.error
|
||||
|
||||
|
||||
def test_apply_does_not_override_existing(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "existing-value")
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([
|
||||
{"key": "OPENAI_API_KEY", "value": "bsm-value"},
|
||||
{"key": "NEW_KEY", "value": "new-value"},
|
||||
])
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess, "run",
|
||||
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary)
|
||||
|
||||
result = bw.apply_bitwarden_secrets(
|
||||
enabled=True, project_id="p",
|
||||
override_existing=False, auto_install=False,
|
||||
)
|
||||
assert result.ok
|
||||
assert "NEW_KEY" in result.applied
|
||||
assert "OPENAI_API_KEY" in result.skipped
|
||||
assert os.environ["OPENAI_API_KEY"] == "existing-value"
|
||||
assert os.environ["NEW_KEY"] == "new-value"
|
||||
|
||||
|
||||
def test_apply_override_existing(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "stale")
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([{"key": "OPENAI_API_KEY", "value": "fresh"}])
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess, "run",
|
||||
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary)
|
||||
|
||||
result = bw.apply_bitwarden_secrets(
|
||||
enabled=True, project_id="p",
|
||||
override_existing=True, auto_install=False,
|
||||
)
|
||||
assert result.ok
|
||||
assert os.environ["OPENAI_API_KEY"] == "fresh"
|
||||
|
||||
|
||||
def test_apply_never_overrides_bootstrap_token(monkeypatch, tmp_path):
|
||||
"""Even with override_existing=True, the access-token var is preserved."""
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.original")
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
payload = _fake_bws_payload([
|
||||
{"key": "BWS_ACCESS_TOKEN", "value": "0.malicious-replacement"},
|
||||
])
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess, "run",
|
||||
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
|
||||
)
|
||||
monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary)
|
||||
|
||||
result = bw.apply_bitwarden_secrets(
|
||||
enabled=True, project_id="p",
|
||||
override_existing=True, auto_install=False,
|
||||
)
|
||||
assert os.environ["BWS_ACCESS_TOKEN"] == "0.original"
|
||||
assert "BWS_ACCESS_TOKEN" in result.skipped
|
||||
|
||||
|
||||
def test_apply_swallows_fetch_errors(monkeypatch, tmp_path):
|
||||
"""A fetch failure produces an error, NOT an exception."""
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.t")
|
||||
fake_binary = tmp_path / "bws"
|
||||
fake_binary.write_text("")
|
||||
monkeypatch.setattr(
|
||||
bw.subprocess, "run",
|
||||
lambda *a, **kw: mock.Mock(returncode=1, stdout="", stderr="bad token"),
|
||||
)
|
||||
monkeypatch.setattr(bw, "find_bws", lambda **kw: fake_binary)
|
||||
|
||||
result = bw.apply_bitwarden_secrets(
|
||||
enabled=True, project_id="p", auto_install=False,
|
||||
)
|
||||
assert not result.ok
|
||||
assert "bad token" in result.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# env_loader integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_loader_skips_when_disabled(tmp_path, monkeypatch):
|
||||
"""No config.yaml present → no BSM call, no crash."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
from hermes_cli.env_loader import _apply_external_secret_sources
|
||||
# Should be a no-op (returns None).
|
||||
assert _apply_external_secret_sources(home) is None
|
||||
|
||||
|
||||
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_apply(**kwargs):
|
||||
called["n"] += 1
|
||||
assert kwargs["enabled"] is True
|
||||
assert kwargs["project_id"] == "proj-1"
|
||||
os.environ["MY_BSM_KEY"] = "from-bsm"
|
||||
return bw.FetchResult(
|
||||
secrets={"MY_BSM_KEY": "from-bsm"},
|
||||
applied=["MY_BSM_KEY"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.secret_sources.bitwarden.apply_bitwarden_secrets",
|
||||
fake_apply,
|
||||
)
|
||||
|
||||
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"
|
||||
116
website/docs/user-guide/secrets/bitwarden.md
Normal file
116
website/docs/user-guide/secrets/bitwarden.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Bitwarden Secrets Manager
|
||||
|
||||
Pull API keys from [Bitwarden Secrets Manager](https://bitwarden.com/products/secrets-manager/) at process startup instead of storing them in plaintext inside `~/.hermes/.env`. One bootstrap secret (a machine-account access token) replaces N per-provider keys, and rotating a credential becomes a single change in the Bitwarden web app.
|
||||
|
||||
## How it works
|
||||
|
||||
1. You create a **machine account** in Bitwarden Secrets Manager, give it read access to a project, and generate an **access token**.
|
||||
2. Hermes stores that single token in `~/.hermes/.env` as `BWS_ACCESS_TOKEN`.
|
||||
3. Every time `hermes` (or the gateway, or a cron job) starts, after `~/.hermes/.env` has loaded, Hermes calls `bws secret list <project_id>` and sets the returned keys into `os.environ`.
|
||||
4. By default Hermes **overrides** values already in your environment, so Bitwarden is the source of truth — rotate a key once in the web app and every Hermes process picks it up on next start. Flip `override_existing: false` in config if you want `.env` to win instead.
|
||||
|
||||
The `bws` binary is auto-downloaded into `~/.hermes/bin/` on first use — no `apt`, no `brew`, no `sudo`.
|
||||
|
||||
## Why machine accounts (and why no 2FA prompt)
|
||||
|
||||
Bitwarden Secrets Manager is designed for non-interactive workloads: machine accounts can't be 2FA-gated because there's no human in the loop. The access token is the credential. Anyone with it can read every secret the machine account has access to, so treat it like a high-value bearer token — store it in `.env` (not `config.yaml`), and revoke + regenerate from the Bitwarden web app if it ever leaks.
|
||||
|
||||
You set up the machine account *in the web app*, where your normal 2FA applies. After that the token is autonomous.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a machine account and access token
|
||||
|
||||
In the [Bitwarden web app](https://vault.bitwarden.com):
|
||||
|
||||
1. Switch to **Secrets Manager** from the product switcher.
|
||||
2. Create or pick a **Project** (e.g. "Hermes keys").
|
||||
3. Add your provider keys as secrets. The secret **Name** becomes the environment variable name — use `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.
|
||||
4. **Machine accounts → New machine account → My Hermes machine** → **Projects** tab → grant Read access to your project.
|
||||
5. **Access tokens** tab → **Create access token** → **Never** expires (or pick a date) → copy the token (starts with `0.`). Bitwarden cannot retrieve it again — keep the copy.
|
||||
|
||||
Secrets Manager is included on the Bitwarden free tier with limits; no paid plan needed to try this.
|
||||
|
||||
### 2. Run the wizard
|
||||
|
||||
```bash
|
||||
hermes secrets bitwarden setup
|
||||
```
|
||||
|
||||
It will:
|
||||
|
||||
1. Download and verify `bws v2.0.0` into `~/.hermes/bin/bws`.
|
||||
2. Prompt you for the access token (input is hidden). Stored in `~/.hermes/.env` as `BWS_ACCESS_TOKEN`.
|
||||
3. List the projects the machine account can see; pick one. Stored in `config.yaml` as `secrets.bitwarden.project_id`.
|
||||
4. Test-fetch the project's secrets and show you which env vars will resolve.
|
||||
5. Flip `secrets.bitwarden.enabled: true`.
|
||||
|
||||
### 3. Confirm
|
||||
|
||||
```bash
|
||||
hermes secrets bitwarden status
|
||||
```
|
||||
|
||||
From now on, every `hermes` invocation pulls fresh secrets at startup. You'll see a one-line summary in stderr the first time secrets are applied in a process.
|
||||
|
||||
## CLI
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `hermes secrets bitwarden setup` | Interactive wizard (install binary, prompt for token, pick project, test fetch) |
|
||||
| `hermes secrets bitwarden status` | Show config + binary version + token presence |
|
||||
| `hermes secrets bitwarden sync` | Dry-run: pull secrets now and show what would be applied |
|
||||
| `hermes secrets bitwarden sync --apply` | Pull and export into the current shell's environment |
|
||||
| `hermes secrets bitwarden install` | Just download the pinned `bws` binary (no auth required) |
|
||||
| `hermes secrets bitwarden disable` | Flip `enabled: false`; leaves token + project id in place |
|
||||
|
||||
## Configuration
|
||||
|
||||
Defaults in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
bitwarden:
|
||||
enabled: false
|
||||
access_token_env: BWS_ACCESS_TOKEN
|
||||
project_id: ""
|
||||
cache_ttl_seconds: 300
|
||||
override_existing: true
|
||||
auto_install: true
|
||||
```
|
||||
|
||||
| Key | Default | What it does |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Master switch. When false, Bitwarden is never contacted. |
|
||||
| `access_token_env` | `BWS_ACCESS_TOKEN` | Env var name that holds the bootstrap token. Change this if you already use `BWS_ACCESS_TOKEN` for something else. |
|
||||
| `project_id` | `""` | UUID of the project to sync from. |
|
||||
| `cache_ttl_seconds` | `300` | How long an in-process fetch result is reused. Set to `0` to disable caching. Cache is per-process; new `hermes` invocations start fresh. |
|
||||
| `override_existing` | `true` | When true, Bitwarden values overwrite anything already in env (so rotation in the web app actually takes effect). Flip to `false` if you want `.env` / shell exports to win locally. |
|
||||
| `auto_install` | `true` | When true, `bws` is auto-downloaded into `~/.hermes/bin/` on first use. |
|
||||
|
||||
## Failure modes
|
||||
|
||||
Bitwarden never blocks Hermes startup. If anything goes wrong, you'll see a one-line warning in stderr and Hermes continues with whatever credentials `.env` already had:
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `BWS_ACCESS_TOKEN is not set` | Enabled in config but token cleared from `.env` | Re-run `hermes secrets bitwarden setup` |
|
||||
| `bws exited 1: invalid access token` | Token revoked or wrong | Generate a new token, re-run setup |
|
||||
| `bws timed out` | Network blocked or Bitwarden API slow | Check connectivity to `api.bitwarden.com` |
|
||||
| `bws binary not available` | `auto_install: false` and `bws` not on PATH | Install manually from [github.com/bitwarden/sdk-sm/releases](https://github.com/bitwarden/sdk-sm/releases) or flip `auto_install` back on |
|
||||
| `Checksum mismatch` | Download corrupted or tampered | Re-run, will retry; if it persists, file an issue |
|
||||
|
||||
## Security notes
|
||||
|
||||
- The bootstrap token (`BWS_ACCESS_TOKEN`) is itself sensitive — anyone with it can read every secret the machine account has access to. Treat it the same as any other API key.
|
||||
- Hermes will refuse to let Bitwarden overwrite the bootstrap token itself, even with `override_existing: true`. If you store `BWS_ACCESS_TOKEN` as a secret inside the project, it's silently skipped during apply.
|
||||
- The `bws` binary download is verified against the published SHA-256 checksum from the same GitHub release. Mismatch aborts the install.
|
||||
- The pinned version (`bws v2.0.0` at time of writing) is updated through PRs to this repo — Hermes does not auto-upgrade `bws` to "latest" because upstream release shapes can change.
|
||||
|
||||
## When NOT to use this
|
||||
|
||||
- **Single-machine personal setups** where `~/.hermes/.env` is fine. You're trading one credential for another and adding a network dependency at startup.
|
||||
- **Air-gapped environments** that can't reach `api.bitwarden.com`.
|
||||
- **CI/CD** where the existing secrets-injection mechanism (GitHub Actions secrets, Vault, etc.) is already set up — pick one path, not two.
|
||||
|
||||
The good case for this is multi-machine fleets, shared dev boxes, gateway VPSes, or any setup where you want centralized rotation and revocation across multiple Hermes installations.
|
||||
9
website/docs/user-guide/secrets/index.md
Normal file
9
website/docs/user-guide/secrets/index.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Secrets
|
||||
|
||||
Hermes can pull API keys from external secret managers at process startup instead of storing them in `~/.hermes/.env`. The bootstrap token for the secret manager lives in `.env`; every other provider key (OpenAI, Anthropic, OpenRouter, etc.) can stay in the manager and rotate centrally.
|
||||
|
||||
Supported:
|
||||
|
||||
- [Bitwarden Secrets Manager](./bitwarden) — `bws` CLI, lazy-installed, free tier works.
|
||||
|
||||
More backends (Vault, AWS Secrets Manager, 1Password CLI) are easy to add behind the same interface — the lift is one module in `agent/secret_sources/` and one CLI handler. File a request if you have a specific one in mind.
|
||||
|
|
@ -27,6 +27,15 @@ const sidebars: SidebarsConfig = {
|
|||
'user-guide/windows-wsl-quickstart',
|
||||
'user-guide/configuration',
|
||||
'user-guide/configuring-models',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Secrets',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'user-guide/secrets/index',
|
||||
'user-guide/secrets/bitwarden',
|
||||
],
|
||||
},
|
||||
'user-guide/sessions',
|
||||
'user-guide/profiles',
|
||||
'user-guide/profile-distributions',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue