Files
hermes-agent/agent/secret_sources/bitwarden.py
T
Teknium 924e290741 refactor(agent/creds): rebase bws/op/command sources onto the shared base+cache substrate
- bitwarden: drop dead apply_bitwarden_secrets (0 refs); encrypted cache uses
  atomic_write_json/entry_from_payload; fetch goes through SecretCache.lookup
  with an encrypted L2 reader; stale-fallback branches merged; _classify_bws_error
  is a rule table; token/override hooks come from the ABC.
- onepassword: same substrate; _missing_binary_error, _fingerprint, _guarded
  dedupe repeated text/logic; _classify_op_error is a rule table.
- command: drop dead parse_secret_output/get_command_secret/list_command_secrets/
  apply_command_secrets (0 refs outside own test); _log helper; tests repointed
  to _run_helper / CommandSource.fetch.
2026-09-02 13:29:46 -07:00

716 lines
27 KiB
Python

"""Bitwarden Secrets Manager (`bws` CLI) integration.
Hermes pulls API keys from Bitwarden Secrets Manager at startup so they don't
have to live in plaintext in ``~/.hermes/.env``.
* ``bws`` is auto-installed into ``<hermes_home>/bin/bws`` on first use: one
pinned version (``_BWS_VERSION``) downloaded from the official GitHub
release and SHA-256-verified against the published checksum file.
* The one bootstrap secret is the access token in ``.env`` (``BWS_ACCESS_TOKEN``
or ``secrets.bitwarden.access_token_env``); every other key can live in BSM.
* One ``bws secret list <project_id> --output json`` call per fetch, cached
in-process and on disk for ``cache_ttl_seconds``.
* Failures NEVER block startup: a one-line warning, then continue with .env.
Subprocess-driven on purpose: one cross-platform binary is easier to
lazy-install than the ``bitwarden-sdk-secrets`` Rust-extension wheel.
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import os
import platform
import re
import shutil
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
SecretCache,
atomic_write_json,
entry_from_payload,
resolve_cache_home,
)
from agent.secret_sources.base import (
ErrorKind,
FetchResult,
SecretSource,
classify_cli_error,
coerce_float,
is_valid_env_name as _is_valid_env_name,
get_source_environment,
run_cli,
source_child_env,
)
logger = logging.getLogger(__name__)
# Pinned upstream version — never auto-resolve "latest": release shape (asset
# names, CLI flags) may change between majors and updates must 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"
_BWS_DOWNLOAD_TIMEOUT = 60
_BWS_RUN_TIMEOUT = 30
# Cache layout: <hermes_home>/cache/bws_cache.json holds only secret VALUES
# (never the access token) — plaintext-equivalent to .env but kept out of it so
# users editing .env don't commit BSM-sourced secrets.
_CacheKey = Tuple[str, str, str] # (access_token_fingerprint, project_id, server_url)
_DISK_CACHE_BASENAME = "bws_cache.json"
_ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json"
_ENCRYPTED_CACHE_VERSION = 1
_ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1"
def _cache_key_str(cache_key: _CacheKey) -> str:
token_fp, project_id, server_url = cache_key
return f"{token_fp}|{project_id}|{server_url}"
_STORE: SecretCache[_CacheKey] = SecretCache(_DISK_CACHE_BASENAME, key_serializer=_cache_key_str)
# Test seams: L1 dict, L2 DiskCache, and its path.
_CACHE = _STORE.memory
_DISK_CACHE = _STORE.disk
_disk_cache_path = _DISK_CACHE.path
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
# ---------------------------------------------------------------------------
# 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]:
"""Managed ``<hermes_home>/bin/bws`` first, then PATH, then optional auto-install."""
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) → upstream asset filename (Rust target-triple style)."""
system = platform.system()
machine = platform.machine().lower()
arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
if system == "Darwin": # universal binary covers Intel + Apple Silicon
return f"bws-macos-universal-{_BWS_VERSION}.zip"
if system == "Windows":
return f"bws-{arch}-pc-windows-msvc-{_BWS_VERSION}.zip"
if system == "Linux":
# glibc default; musl only if ldd says so (glibc prints to stderr, musl
# to stdout). A wrong guess surfaces as a loader error we catch.
libc = "gnu"
try:
res = subprocess.run(
["ldd", "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=2,
stdin=subprocess.DEVNULL,
)
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; raises on any failure.
The auto-install path catches; ``hermes secrets bitwarden setup`` lets the
error propagate so the wizard can show it.
"""
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())
extracted = _safe_extract_member(zf, member, tmp)
# Stage 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, 0o755)
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 standard ``sha256sum`` output (``<hex> <filename>`` 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 in the zip; tolerate a top-level dir, prefer the shortest path."""
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]}...)"
)
candidates.sort(key=len)
return candidates[0]
def _safe_extract_member(
zf: zipfile.ZipFile, member: str, dest_dir: Path
) -> Path:
"""Extract one member, refusing zip-slip (``../`` or absolute member names).
``ZipFile.extract`` joins the member onto ``dest_dir`` without verifying the
result stays inside it, so containment is checked here first.
"""
dest_root = os.path.realpath(dest_dir)
target = os.path.realpath(os.path.join(dest_root, member))
try: # commonpath raises for e.g. different Windows drives — treat as escape
contained = os.path.commonpath([dest_root, target]) == dest_root
except ValueError:
contained = False
if not contained or target == dest_root:
raise RuntimeError(
f"Refusing to extract unsafe archive member {member!r}: "
f"it escapes the extraction directory"
)
zf.extract(member, dest_root)
return Path(target)
# ---------------------------------------------------------------------------
# Encrypted last-good cache (opt-in)
# ---------------------------------------------------------------------------
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 _b64e(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii")
def _b64d(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"), validate=True)
def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
"""HKDF the local cache key from the bootstrap BWS token.
cryptography is imported lazily: most CLI commands import this module while
building argparse, and eagerly mapping ``_rust.pyd`` on Windows blocks the
updater from replacing that file.
"""
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=_ENCRYPTED_CACHE_INFO,
).derive(access_token.encode("utf-8"))
def _write_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
entry: _CachedFetch,
home_path: Optional[Path] = None,
) -> None:
"""Persist an AES-GCM encrypted last-good entry atomically (best-effort).
The raw token is never stored; it only derives the key. A successful write
completes migration, so the legacy plaintext cache is removed.
"""
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
salt = os.urandom(16)
nonce = os.urandom(12)
serialized_key = _cache_key_str(cache_key)
key = _derive_encrypted_cache_key(access_token, salt)
plaintext = json.dumps(
{"secrets": entry.secrets, "fetched_at": entry.fetched_at},
separators=(",", ":"),
).encode("utf-8")
ciphertext = AESGCM(key).encrypt(
nonce, plaintext, serialized_key.encode("utf-8")
)
payload = {
"version": _ENCRYPTED_CACHE_VERSION,
"key": serialized_key,
"salt": _b64e(salt),
"nonce": _b64e(nonce),
"ciphertext": _b64e(ciphertext),
}
atomic_write_json(_encrypted_disk_cache_path(home_path), payload,
tmp_prefix=".bws_cache_enc_")
_STORE.disk.clear(home_path)
except Exception: # noqa: BLE001 — best-effort cache only
return
def _read_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
max_age_seconds: float,
home_path: Optional[Path] = None,
) -> Optional[_CachedFetch]:
"""Decrypted encrypted-cache entry if it matches ``cache_key`` and is in-window."""
if max_age_seconds <= 0:
return None
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
payload = json.loads(_encrypted_disk_cache_path(home_path).read_text(encoding="utf-8"))
serialized_key = _cache_key_str(cache_key)
if (not isinstance(payload, dict)
or payload.get("version") != _ENCRYPTED_CACHE_VERSION
or payload.get("key") != serialized_key):
return None
salt = _b64d(str(payload.get("salt", "")))
nonce = _b64d(str(payload.get("nonce", "")))
ciphertext = _b64d(str(payload.get("ciphertext", "")))
key = _derive_encrypted_cache_key(access_token, salt)
entry = entry_from_payload(json.loads(AESGCM(key).decrypt(
nonce, ciphertext, serialized_key.encode("utf-8")
).decode("utf-8")))
if entry is None:
return None
entry_age = time.time() - entry.fetched_at
if entry_age < 0 or entry_age > max_age_seconds:
return None
return entry
except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors
return None
# ---------------------------------------------------------------------------
# Secret fetch
# ---------------------------------------------------------------------------
def fetch_bitwarden_secrets(
*,
access_token: str,
project_id: str,
binary: Optional[Path] = None,
cache_ttl_seconds: float = 300,
use_cache: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> Tuple[Dict[str, str], List[str]]:
"""Pull the secrets for ``project_id`` from BSM → ``(secrets, warnings)``.
``server_url`` selects a region / self-hosted instance (``BWS_SERVER_URL``;
empty = bws default, US Cloud). ``cache_ttl_seconds`` governs the fresh
cache. With ``encrypted_cache_enabled`` fresh entries are written AES-GCM
encrypted instead of plaintext, and a last-good entry may be served after
NETWORK/TIMEOUT failures for up to ``encrypted_cache_max_stale_seconds`` —
independent of the fresh TTL, so ``cache_ttl_seconds: 0`` can coexist with
a break-glass offline cache.
Raises ``RuntimeError`` for fatal conditions (missing binary, auth failure,
unparseable output); the env_loader path catches, the setup wizard lets 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, server_url or "")
def _read_encrypted(max_age: float) -> Optional[_CachedFetch]:
return _read_encrypted_disk_cache(
cache_key=cache_key, access_token=access_token,
max_age_seconds=max_age, home_path=home_path,
)
if use_cache and cache_ttl_seconds > 0:
# L2 (~5ms) vs ~380ms for `bws secret list`.
cached = _STORE.lookup(
cache_key, cache_ttl_seconds, home_path,
read_disk=(lambda: _read_encrypted(cache_ttl_seconds)) if encrypted_cache_enabled else None,
)
if cached is not None:
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`."
)
try:
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
except RuntimeError as exc:
# Stale fallback ONLY for transport failures — never AUTH_FAILED or a
# malformed-output INTERNAL error, where serving old secrets would mask
# a real config/credential problem. Without it a fleet sharing one BSM
# project all stops on a single network blip.
# When the encrypted cache is enabled it is the ONLY fallback consulted
# (the at-rest payload must never be plaintext). Otherwise the plain
# DiskCache is read with ttl=inf (a stale hit is the point) but only if
# the caller's real TTL > 0 — ttl<=0 means caching is opted out.
kind = _classify_bws_error(str(exc))
if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT):
stale = label = None
if encrypted_cache_enabled:
stale = _read_encrypted(encrypted_cache_max_stale_seconds)
label = "stale ENCRYPTED disk cache"
elif cache_ttl_seconds > 0:
stale = _STORE.disk.read(cache_key, float("inf"), home_path)
label = "stale disk cache"
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_STORE.memory[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); falling back to {label} ({int(age)}s old)"
]
raise
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
if use_cache:
if cache_ttl_seconds > 0:
_STORE.memory[cache_key] = entry
if encrypted_cache_enabled:
# Encryption is the storage policy; max_stale only gates outage
# reads. Never fall back to plaintext because stale fallback is off.
_write_encrypted_disk_cache(
cache_key=cache_key, access_token=access_token,
entry=entry, home_path=home_path,
)
else:
_STORE.disk.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
def _summarize_bws_stderr(raw: str) -> str:
"""Reduce a bws (Rust color-eyre) error dump to its cause line(s).
Keeps the numbered ``0: ...`` cause lines (joined with ``; ``), drops
everything from ``Location:``/``Backtrace omitted`` on, and falls back to
the stripped raw text when the shape is unrecognized.
"""
text = raw.replace("\x1b", "").strip()
if not text:
return text
causes: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")):
break
if stripped in ("", "Error:"):
continue
stripped = re.sub(r"^\d+:\s*", "", stripped)
if stripped:
causes.append(stripped)
return "; ".join(causes) if causes else text
def _run_bws_list(
bws: Path, access_token: str, project_id: str, server_url: str = ""
) -> Tuple[Dict[str, str], List[str]]:
cmd = [str(bws), "secret", "list", project_id, "--output", "json"]
# The bws child intentionally receives the access token; a profile-local
# fetch must not inherit sibling credentials (source_child_env).
env = source_child_env()
env["BWS_ACCESS_TOKEN"] = access_token
env.setdefault("NO_COLOR", "1")
# Empty server_url keeps whatever BWS_SERVER_URL the shell already had.
if server_url:
env["BWS_SERVER_URL"] = server_url
proc = run_cli(cmd, env=env, timeout=_BWS_RUN_TIMEOUT, label="bws",
timeout_message=f"bws timed out after {_BWS_RUN_TIMEOUT}s fetching secrets")
if proc.returncode != 0:
err = _summarize_bws_stderr(proc.stderr or proc.stdout or "")
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
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class BitwardenSource(SecretSource):
"""Bitwarden Secrets Manager as a registered **bulk** source.
``fetch()`` only fetches — precedence, overrides and the ``os.environ``
writes are the orchestrator's. Bulk: it injects every secret in the BSM
project, so explicit per-var bindings from mapped sources outrank it.
"""
name = "bitwarden"
label = "Bitwarden Secrets Manager"
shape = "bulk"
scheme = "bws"
token_env_key = "access_token_env"
default_token_env = "BWS_ACCESS_TOKEN"
# override_existing defaults True: the point of BSM is centralized rotation
# — a stale .env line must not have the final say.
override_existing_default = True
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"access_token_env": {
"description": "Env var holding the machine-account access token",
"default": "BWS_ACCESS_TOKEN",
},
"project_id": {"description": "BSM project UUID", "default": ""},
"cache_ttl_seconds": {
"description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse",
"default": 300,
},
"encrypted_cache": {
"description": "Encrypted last-good cache for network/timeout fallback",
"default": {"enabled": False, "max_stale_seconds": 0},
},
"override_existing": {"description": "BSM values overwrite .env/shell values", "default": True},
"auto_install": {"description": "Auto-download the pinned bws binary", "default": True},
"server_url": {"description": "Region / self-hosted endpoint (empty = US Cloud)", "default": ""},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
access_token_env = self.token_env(cfg)
access_token = get_source_environment().get(access_token_env, "").strip()
if not access_token:
return result.fail(
f"secrets.bitwarden.enabled is true but {access_token_env} is "
"not set. Run `hermes secrets bitwarden setup`.",
ErrorKind.NOT_CONFIGURED,
)
project_id = str(cfg.get("project_id") or "")
if not project_id:
return result.fail(
"secrets.bitwarden.project_id is empty. "
"Run `hermes secrets bitwarden setup`.",
ErrorKind.NOT_CONFIGURED,
)
binary = find_bws(install_if_missing=bool(cfg.get("auto_install", True)))
result.binary_path = binary
if binary is None:
return result.fail(
"bws binary not available and auto-install is disabled. "
"Run `hermes secrets bitwarden setup` to install.",
ErrorKind.BINARY_MISSING,
)
encrypted_cfg = cfg.get("encrypted_cache")
encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {}
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
project_id=project_id,
binary=binary,
cache_ttl_seconds=coerce_float(cfg.get("cache_ttl_seconds", 300), 300.0),
server_url=str(cfg.get("server_url", "") or "").strip(),
home_path=home_path,
encrypted_cache_enabled=bool(encrypted_cfg.get("enabled", False)),
encrypted_cache_max_stale_seconds=coerce_float(
encrypted_cfg.get("max_stale_seconds", 0), 0.0),
)
except RuntimeError as exc:
result.fail(str(exc), _classify_bws_error(str(exc)))
if result.error_kind == ErrorKind.AUTH_FAILED:
# Say what the raw OAuth reject means for the user first.
result.error = (
"Bitwarden rejected the machine-account access token "
f"({access_token_env}) — it was likely revoked, expired, "
f"or belongs to another region. ({result.error})"
)
return result
result.secrets = secrets
result.warnings.extend(warnings)
return result
def remediation(self, kind, cfg: dict) -> str:
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
return (
"Run `hermes secrets bitwarden token` to paste a fresh access "
"token (create one in the Bitwarden web app: Secrets Manager → "
"Machine accounts → Access tokens). Wrong region? Re-run "
"`hermes secrets bitwarden setup` and pick EU/self-hosted."
)
return super().remediation(kind, cfg)
# First matching rule wins. The BSM identity endpoint rejects a revoked /
# expired machine-account token with an OAuth-style
# `[400 Bad Request] {"error":"invalid_client"}`, hence those AUTH tokens.
_BWS_ERROR_RULES = (
(ErrorKind.TIMEOUT, ("timed out",)),
(ErrorKind.BINARY_MISSING, ("binary not available", "failed to invoke")),
(ErrorKind.AUTH_FAILED, ("unauthorized", "invalid token", "access token", "401", "403",
"invalid_client", "invalid_grant", "400 bad request")),
(ErrorKind.NETWORK, ("network", "connection", "resolve", "download", "dns")),
)
def _classify_bws_error(message: str) -> ErrorKind:
return classify_cli_error(message, _BWS_ERROR_RULES)
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches (plaintext and encrypted).
Used after a token rotation so the next startup fetches fresh instead of
serving a pull cached under the old token's fingerprint.
"""
_STORE.clear(home_path)
try:
_encrypted_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
_reset_cache_for_tests = clear_caches