{"repo":"NousResearch/hermes-agent","public_id":"40275","display_label":"Bitwarden token validation in secrets status command","summary":"A bug where `hermes secrets bitwarden status` incorrectly reports a token as valid when it is not, along with related fix attempts to add proper token validation to the status output.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":69055,"merged_at":"2026-07-22T10:20:10+00:00"},"resolution":{"state":"resolved","by":"pr-69055"},"freshness":{"state":"verified","last_verified_at":"2026-07-22T10:20:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":40275,"pr_number":68605,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-21T13:41:04+00:00"},{"issue_number":40275,"pr_number":69055,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-22T10:20:10+00:00"}],"members":[{"kind":"issue","number":40275,"title":"Bug: `hermes secrets bitwarden status` shows 'Token in env: yes' even when token is invalid","url":null,"state":"closed"},{"kind":"pr","number":40290,"title":"fix(secrets): validate bitwarden status token","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#69055","number":69055},{"kind":"pr","value":"#68605","number":68605},{"kind":"pr","value":"#40275","number":40275}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68605,"title":"feat(secrets): one-command token rotation + actionable startup errors for all secret sources","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/secret_sources/base.py","agent/secret_sources/bitwarden.py","agent/secret_sources/onepassword.py","hermes_cli/env_loader.py","hermes_cli/onepassword_secrets_cli.py","hermes_cli/secrets_cli.py","tests/hermes_cli/test_secrets_token_rotation.py","tests/secret_sources/test_error_remediation.py","website/docs/developer-guide/secret-source-plugin.md","website/docs/reference/cli-commands.md","website/docs/user-guide/secrets/bitwarden.md","website/docs/user-guide/secrets/onepassword.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md"],"fix_hunks":[{"file":"agent/secret_sources/base.py","hunks":["@@ -190,6 +190,45 @@ def config_schema(self) -> dict:\n         \"\"\"\n         return {}\n \n+    def remediation(self, kind: Optional[\"ErrorKind\"], cfg: dict) -> str:\n+        \"\"\"One-line, actionable next step for a failed fetch.\n+\n+        Called by the startup status printer (and ``hermes secrets ...\n+        status``) right after a fetch error is surfaced, so the user sees\n+        *what to run* next to fix it — not just what broke.  Sources\n+        should override this to point at their own CLI verbs (e.g.\n+        ``hermes secrets bitwarden token`` for AUTH_FAILED).  Return an\n+        empty string to suppress the hint.\n+\n+        Must never raise and must not perform I/O — it's a pure\n+        kind→string mapping on the startup path.\n+        \"\"\"\n+        generic = {\n+            ErrorKind.NOT_CONFIGURED: (\n+                f\"Run `hermes secrets {self.name} setup` to finish configuration.\"\n+            ),\n+            ErrorKind.BINARY_MISSING: (\n+                f\"Run `hermes secrets {self.name} setup` to install the helper CLI.\"\n+            ),\n+            ErrorKind.AUTH_FAILED: (\n+                f\"Credentials rejected — run `hermes secrets {self.name} setup` \"\n+                \"to re-authenticate.\"\n+            ),\n+            ErrorKind.AUTH_EXPIRED: (\n+                f\"Credentials expired — run `hermes secrets {self.name} setup` \"\n+                \"to re-authenticate.\"\n+            ),\n+            ErrorKind.NETWORK: (\n+                \"Network problem reaching the secrets backend — check \"\n+                \"connectivity and retry.\"\n+            ),\n+            ErrorKind.TIMEOUT: (\n+                f\"Backend was slow — raise secrets.{self.name}.timeout_seconds \"\n+                \"if this recurs.\"\n+            ),\n+        }\n+        return generic.get(kind, \"\") if kind is not None else \"\"\n+\n \n # ---------------------------------------------------------------------------\n # Shared helpers — use these instead of hand-rolling per backend"]},{"file":"agent/secret_sources/bitwarden.py","hunks":["@@ -34,6 +34,7 @@\n import logging\n import os\n import platform\n+import re\n import shutil\n import stat\n import subprocess","@@ -415,6 +416,39 @@ def fetch_bitwarden_secrets(\n     return secrets, warnings\n \n \n+def _summarize_bws_stderr(raw: str) -> str:\n+    \"\"\"Reduce a bws (Rust color-eyre) error dump to its cause line(s).\n+\n+    bws failures look like::\n+\n+        Error:\n+           0: Received error message from server: [400 Bad Request] {\"error\":\"invalid_client\"}\n+\n+        Location:\n+           crates/bws/src/main.rs:108\n+        ...\n+\n+    Everything from ``Location:`` on is diagnostic noise for a Hermes\n+    user.  Keep the numbered cause lines (joined), drop the rest, and\n+    fall back to the stripped raw text when the shape is unrecognized.\n+    \"\"\"\n+    text = raw.replace(\"\\x1b\", \"\").strip()\n+    if not text:\n+        return text\n+    causes: List[str] = []\n+    for line in text.splitlines():\n+        stripped = line.strip()\n+        if stripped.startswith((\"Location:\", \"Backtrace omitted\", \"Run with \")):\n+            break\n+        if stripped in (\"\", \"Error:\"):\n+            continue\n+        # Cause lines are numbered \"0: ...\", \"1: ...\" — strip the index.\n+        stripped = re.sub(r\"^\\d+:\\s*\", \"\", stripped)\n+        if stripped:\n+            causes.append(stripped)\n+    return \"; \".join(causes) if causes else text\n+\n+\n def _run_bws_list(\n     bws: Path, access_token: str, project_id: str, server_url: str = \"\"\n ) -> Tuple[Dict[str, str], List[str]]:","@@ -448,9 +482,11 @@ def _run_bws_list(\n         raise RuntimeError(f\"failed to invoke bws: {exc}\") from exc\n \n     if proc.returncode != 0:\n-        # bws writes auth/network errors to stderr in plain English.\n-        # Strip ANSI just in case and surface the first 200 chars.\n-        err = (proc.stderr or proc.stdout or \"\").strip().replace(\"\\x1b\", \"\")\n+        # bws writes auth/network errors to stderr as a Rust error-report\n+        # dump (color-eyre): an \"Error:\" header, indented cause lines, then\n+        # \"Location:\" / \"Backtrace omitted\" noise.  Strip ANSI and boil it\n+        # down to the meaningful cause line(s) before surfacing.\n+        err = _summarize_bws_stderr(proc.stderr or proc.stdout or \"\")\n         raise RuntimeError(\n             f\"bws exited {proc.returncode}: {err[:200]}\"\n         )","@@ -690,12 +726,30 @@ def fetch(self, cfg: dict, home_path: Path) -> FetchResult:\n         except RuntimeError as exc:\n             result.error = str(exc)\n             result.error_kind = _classify_bws_error(str(exc))\n+            if result.error_kind == ErrorKind.AUTH_FAILED:\n+                # Translate the raw OAuth reject into what it actually means\n+                # for the user before the mechanics.\n+                result.error = (\n+                    \"Bitwarden rejected the machine-account access token \"\n+                    f\"({access_token_env}) — it was likely revoked, expired, \"\n+                    f\"or belongs to another region.  ({result.error})\"\n+                )\n             return result\n \n         result.secrets = secrets\n         result.warnings.extend(warnings)\n         return result\n \n+    def remediation(self, kind, cfg: dict) -> str:\n+        if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):\n+            return (\n+                \"Run `hermes secrets bitwarden token` to paste a fresh access \"\n+                \"token (create one in the Bitwarden web app: Secrets Manager → \"\n+                \"Machine accounts → Access tokens).  Wrong region?  Re-run \"\n+                \"`hermes secrets bitwarden setup` and pick EU/self-hosted.\"\n+            )\n+        return super().remediation(kind, cfg)\n+\n \n def _classify_bws_error(message: str) -> ErrorKind:\n     \"\"\"Best-effort mapping of bws failure text onto the shared taxonomy.\"\"\"","@@ -705,7 +759,13 @@ def _classify_bws_error(message: str) -> ErrorKind:\n     if \"binary not available\" in lowered or \"failed to invoke\" in lowered:\n         return ErrorKind.BINARY_MISSING\n     if any(tok in lowered for tok in (\"unauthorized\", \"invalid token\",\n-                                      \"access token\", \"401\", \"403\")):\n+                                      \"access token\", \"401\", \"403\",\n+                                      # The BSM identity endpoint rejects a\n+                                      # revoked/expired/deleted machine-account\n+                                      # token with an OAuth-style\n+                                      # `[400 Bad Request] {\"error\":\"invalid_client\"}`.\n+                                      \"invalid_client\", \"invalid_grant\",\n+                                      \"400 bad request\")):\n         return ErrorKind.AUTH_FAILED\n     if any(tok in lowered for tok in (\"network\", \"connection\", \"resolve\",\n                                       \"download\", \"dns\")):","@@ -718,6 +778,17 @@ def _classify_bws_error(message: str) -> ErrorKind:\n # ---------------------------------------------------------------------------\n \n \n+def clear_caches(home_path: Optional[Path] = None) -> None:\n+    \"\"\"Drop in-process AND disk caches.\n+\n+    Used after a token rotation (`hermes secrets bitwarden token`) so the\n+    next startup fetches fresh with the new credential instead of serving\n+    a pull cached under the old token's fingerprint.\n+    \"\"\"\n+    _CACHE.clear()\n+    _DISK_CACHE.clear(home_path)\n+\n+\n def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:\n     \"\"\"Clear in-process AND disk caches.\n ","@@ -725,5 +796,4 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:\n     Without it we fall back to the same default resolution as the cache\n     writer itself.\n     \"\"\"\n-    _CACHE.clear()\n-    _DISK_CACHE.clear(home_path)\n+    clear_caches(home_path)"]},{"file":"agent/secret_sources/onepassword.py","hunks":["@@ -607,6 +607,24 @@ def fetch(self, cfg: dict, home_path: Path) -> FetchResult:\n         result.warnings.extend(fetch_warnings)\n         return result\n \n+    def remediation(self, kind, cfg: dict) -> str:\n+        if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):\n+            token_env = [redacted:secret-assignment]\n+            if isinstance(cfg, dict):\n+                token_env = [redacted:secret-assignment]\"service_account_token_env\") or token_env)\n+            return (\n+                \"Run `hermes secrets onepassword token` to paste a fresh \"\n+                f\"service-account token ({token_env}), or `op signin` for an \"\n+                \"interactive session.\"\n+            )\n+        if kind == ErrorKind.BINARY_MISSING:\n+            return (\n+                \"Install the 1Password CLI \"\n+                \"(https://developer.1password.com/docs/cli/get-started/) or \"\n+                \"set secrets.onepassword.binary_path.\"\n+            )\n+        return super().remediation(kind, cfg)\n+\n \n def _classify_op_error(message: str) -> ErrorKind:\n     \"\"\"Best-effort mapping of op failure text onto the shared taxonomy.\"\"\"","@@ -633,11 +651,21 @@ def _classify_op_error(message: str) -> ErrorKind:\n # ---------------------------------------------------------------------------\n \n \n+def clear_caches(home_path: Optional[Path] = None) -> None:\n+    \"\"\"Drop in-process AND disk caches.\n+\n+    Used after a token rotation (`hermes secrets onepassword token`) so\n+    the next startup resolves fresh with the new credential instead of\n+    serving values cached under the old token's fingerprint.\n+    \"\"\"\n+    _CACHE.clear()\n+    _DISK_CACHE.clear(home_path)\n+\n+\n def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:\n     \"\"\"Clear in-process AND disk caches.\n \n     Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.\n     Without it we fall back to the same default resolution as the writer.\n     \"\"\"\n-    _CACHE.clear()\n-    _DISK_CACHE.clear(home_path)\n+    clear_caches(home_path)"]},{"file":"hermes_cli/env_loader.py","hunks":["@@ -437,12 +437,35 @@ def _apply_external_secret_sources(home_path: Path) -> None:\n             )\n         if src.result.error:\n             print(f\"  {src.label}: {src.result.error}\", file=sys.stderr)\n+            hint = _remediation_hint(src.name, src.result.error_kind, cfg)\n+            if hint:\n+                print(f\"  {src.label}: → {hint}\", file=sys.stderr)\n         for warn in src.result.warnings:\n             print(f\"  {src.label}: {warn}\", file=sys.stderr)\n     for conflict in report.conflicts:\n         print(f\"  Secret sources: {conflict}\", file=sys.stderr)\n \n \n+def _remediation_hint(source_name: str, error_kind, secrets_cfg: dict) -> str:\n+    \"\"\"Ask the failed source for its one-line fix-it hint.\n+\n+    Defensive wrapper: remediation() is a pure mapping and shouldn't\n+    raise, but a plugin source could — and startup must never break on\n+    a status line.\n+    \"\"\"\n+    try:\n+        from agent.secret_sources.registry import get_source\n+\n+        source = get_source(source_name)\n+        if source is None:\n+            return \"\"\n+        src_cfg = secrets_cfg.get(source_name)\n+        src_cfg = src_cfg if isinstance(src_cfg, dict) else {}\n+        return str(source.remediation(error_kind, src_cfg) or \"\").strip()\n+    except Exception:  # noqa: BLE001 — hints must never block startup\n+        return \"\"\n+\n+\n def _load_secrets_config(home_path: Path) -> dict:\n     \"\"\"Read just the ``secrets:`` section out of config.yaml.\n "]},{"file":"hermes_cli/onepassword_secrets_cli.py","hunks":["@@ -18,6 +18,7 @@\n import argparse\n import os\n import subprocess\n+import sys\n from pathlib import Path\n from typing import Optional\n ","@@ -32,6 +33,7 @@\n     save_config,\n     save_env_value,\n )\n+from hermes_cli.secret_prompt import masked_secret_prompt\n \n _DEFAULT_TOKEN_ENV = \"OP_SERVICE_ACCOUNT_TOKEN\"\n _DOCS_URL = \"https://developer.1password.com/docs/cli/get-started/\"","@@ -71,6 +73,21 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:\n     status = sub.add_parser(\"status\", help=\"Show config + op binary + references\")\n     status.set_defaults(func=cmd_status)\n \n+    token = sub.add_parser(\n+        \"token\",\n+        help=\"Rotate the service-account token: validate and store it in .env\",\n+    )\n+    token.add_argument(\n+        \"--token\",\n+        help=\"Provide the new token non-interactively (default: masked prompt)\",\n+    )\n+    token.add_argument(\n+        \"--no-verify\",\n+        action=\"store_true\",\n+        help=\"Store without probing 1Password first (not recommended)\",\n+    )\n+    token.set_defaults(func=cmd_token)\n+\n     set_p = sub.add_parser(\"set\", help=\"Map an env var to an op:// reference\")\n     set_p.add_argument(\"env_var\", help=\"Environment variable name, e.g. OPENAI_API_KEY\")\n     set_p.add_argument(\"reference\", help=\"1Password reference, e.g. op://Private/OpenAI/api key\")","@@ -282,6 +299,68 @@ def cmd_remove(args: argparse.Namespace) -> int:\n     return 0\n \n \n+def cmd_token(args: argparse.Namespace) -> int:\n+    \"\"\"Rotate the 1Password service-account token without the full setup flow.\n+\n+    Prompts for (or accepts via ``--token``) a new service-account token,\n+    verifies it with ``op whoami`` (unless ``--no-verify``), and only then\n+    persists it to .env — so a bad paste never bricks the working token.\n+    \"\"\"\n+    console = Console()\n+    cfg = load_config()\n+    op_cfg = (cfg.get(\"secrets\") or {}).get(\"onepassword\") or {}\n+    token_env = op_cfg.get(\"service_account_token_env\", _DEFAULT_TOKEN_ENV)\n+    account = str(op_cfg.get(\"account\", \"\") or \"\").strip()\n+    binary_path = str(op_cfg.get(\"binary_path\", \"\") or \"\").strip()\n+\n+    token = [redacted:secret-assignment] or \"\").strip()\n+    if not token:\n+        if not sys.stdin.isatty():\n+            console.print(\"[red]No TTY — pass the token with --token.[/red]\")\n+            return 1\n+        console.print(\n+            \"Create a new service-account token at \"\n+            \"https://my.1password.com → Developer → Service Accounts.\\n\"\n+        )\n+        token = [redacted:secret-assignment]\"Paste new token ({token_env}): \").strip()\n+    if not token:\n+        console.print(\"[red]Empty token, aborting.[/red]\")\n+        return 1\n+\n+    if not args.no_verify:\n+        binary = op_src.find_op(binary_path)\n+        if binary is None:\n+            console.print(\n+                f\"[red]op CLI not found — install it ({_DOCS_URL}) or \"\n+                \"re-run with --no-verify to store anyway.[/red]\"\n+            )\n+            return 1\n+        console.print(\"Verifying with `op whoami`…\")\n+        who = _op_whoami(binary, account, token_value=[redacted:secret-assignment]\n+        if who is None:\n+            console.print(\n+                \"[red]✗ New token was rejected by op — nothing was changed.[/red]\"\n+            )\n+            return 1\n+        console.print(f\"[green]✓ Token accepted[/green] ({who}).\")\n+\n+    save_env_value(token_env, token)\n+    os.environ[token_env] = token\n+    # Cached resolutions are keyed on the previous token's fingerprint;\n+    # drop them so the next startup resolves fresh with the new credential.\n+    op_src.clear_caches()\n+    console.print(\n+        f\"[green]✓[/green] stored in {get_env_path()} as {token_env}.  \"\n+        \"Takes effect on the next Hermes invocation.\"\n+    )\n+    if not op_cfg.get(\"enabled\"):\n+        console.print(\n+            \"[yellow]Note: the 1Password integration is currently disabled — \"\n+            \"run `hermes secrets onepassword setup` to turn it on.[/yellow]\"\n+        )\n+    return 0\n+\n+\n def cmd_sync(args: argparse.Namespace) -> int:\n     console = Console()\n     cfg = load_config()","@@ -417,13 +496,26 @@ def _op_version(binary: Path) -> str:\n     return \"version unknown\"\n \n \n-def _op_whoami(binary: Path, account: str) -> Optional[str]:\n-    \"\"\"Return a short identity string if op is authenticated, else None.\"\"\"\n+def _op_whoami(\n+    binary: Path, account: str, *, token_value: str = \"\"\n+) -> Optional[str]:\n+    \"\"\"Return a short identity string if op is authenticated, else None.\n+\n+    ``token_value``, when given, is passed to the child as\n+    ``OP_SERVICE_ACCOUNT_TOKEN`` so a candidate token can be probed\n+    without touching the caller's environment.\n+    \"\"\"\n     cmd = [str(binary), \"whoami\"]\n     if account:\n         cmd += [\"--account\", account]\n+    env = os.environ.copy()\n+    env.setdefault(\"NO_COLOR\", \"1\")\n+    if token_value:\n+        env[\"OP_SERVICE_ACCOUNT_TOKEN\"] = token_value\n     try:\n-        res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)\n+        res = subprocess.run(\n+            cmd, env=env, capture_output=True, text=True, timeout=10\n+        )\n     except (OSError, subprocess.TimeoutExpired):\n         return None\n     if res.returncode != 0:"]},{"file":"hermes_cli/secrets_cli.py","hunks":["@@ -71,6 +71,21 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:\n     status = sub.add_parser(\"status\", help=\"Show config + binary + last fetch\")\n     status.set_defaults(func=cmd_status)\n \n+    token = sub.add_parser(\n+        \"token\",\n+        help=\"Rotate the access token: validate a new one and store it in .env\",\n+    )\n+    token.add_argument(\n+        \"--access-token\",\n+        help=\"Provide the new token non-interactively (default: masked prompt)\",\n+    )\n+    token.add_argument(\n+        \"--no-verify\",\n+        action=\"store_true\",\n+        help=\"Store without probing Bitwarden first (not recommended)\",\n+    )\n+    token.set_defaults(func=cmd_token)\n+\n     sync = sub.add_parser(\"sync\", help=\"Fetch secrets now and report what changed\")\n     sync.add_argument(\n         \"--apply\",","@@ -337,6 +352,87 @@ def cmd_status(args: argparse.Namespace) -> int:\n     return 0\n \n \n+def cmd_token(args: argparse.Namespace) -> int:\n+    \"\"\"Rotate the BSM access token without re-running the whole setup wizard.\n+\n+    Prompts for (or accepts via ``--access-token``) a new machine-account\n+    token, probes Bitwarden with it (unless ``--no-verify``), and only then\n+    persists it to .env — so a bad paste never bricks the working token.\n+    \"\"\"\n+    console = Console()\n+    cfg = load_config()\n+    bw_cfg = (cfg.get(\"secrets\") or {}).get(\"bitwarden\") or {}\n+    token_env = bw_cfg.get(\"access_token_env\", \"BWS_ACCESS_TOKEN\")\n+    server_url = str(bw_cfg.get(\"server_url\", \"\") or \"\").strip()\n+\n+    token = [redacted:secret-assignment] or \"\").strip()\n+    if not token:\n+        if not sys.stdin.isatty():\n+            console.print(\n+                \"[red]No TTY — pass the token with --access-token.[/red]\"\n+            )\n+            return 1\n+        console.print(\n+            \"Create a new token in the Bitwarden web app:\\n\"\n+            \"  Secrets Manager → Machine accounts → [your account] → \"\n+            \"Access tokens → Create access token\\n\"\n+        )\n+        token = [redacted:secret-assignment]\"Paste new access token ({token_env}): \").strip()\n+    if not token:\n+        console.print(\"[red]Empty token, aborting.[/red]\")\n+        return 1\n+    if not token.startswith(\"0.\"):\n+        console.print(\n+            \"[yellow]Warning: token doesn't start with '0.' — usually that means \"\n+            \"you pasted something other than a BSM access token.[/yellow]\"\n+        )\n+\n+    if not args.no_verify:\n+        binary = bw.find_bws(install_if_missing=True)\n+        if binary is None:\n+            console.print(\n+                \"[red]bws binary not available — cannot verify.  \"\n+                \"Re-run with --no-verify to store anyway.[/red]\"\n+            )\n+            return 1\n+        console.print(\"Verifying against Bitwarden…\")\n+        projects = _list_projects(binary, token, console, server_url=server_url)\n+        if projects is None:\n+            console.print(\n+                \"[red]✗ New token was rejected — nothing was changed.[/red]\"\n+            )\n+            return 1\n+        console.print(\n+            f\"[green]✓ Token accepted[/green] \"\n+            f\"({len(projects)} project{'s' if len(projects) != 1 else ''} visible).\"\n+        )\n+        project_id = str(bw_cfg.get(\"project_id\", \"\") or \"\")\n+        if project_id and projects and project_id not in {p[\"id\"] for p in projects}:\n+            console.print(\n+                f\"[yellow]Warning: configured project {project_id} is not visible \"\n+                \"to this machine account.  Grant it access in the Bitwarden web \"\n+                \"app or re-run `hermes secrets bitwarden setup` to pick a \"\n+                \"different project.[/yellow]\"\n+            )\n+\n+    save_env_value(token_env, token)\n+    os.environ[token_env] = token\n+    # Old cached pulls are keyed on the previous token's fingerprint; drop\n+    # them so the next startup fetches fresh with the new credential.\n+    bw.clear_caches()\n+    console.print(\n+        f\"[green]✓[/green] stored in {get_env_path()} as {token_env}.  \"\n+        \"Takes effect on the next Hermes invocation.\"\n+    )\n+    if not bw_cfg.get(\"enabled\"):\n+        console.print(\n+            \"[yellow]Note: the Bitwarden integration is currently disabled — \"\n+            \"run `hermes secrets bitwarden setup` (or set \"\n+            \"secrets.bitwarden.enabled: true) to turn it on.[/yellow]\"\n+        )\n+    return 0\n+\n+\n def cmd_sync(args: argparse.Namespace) -> int:\n     console = Console()\n     cfg = load_config()"]},{"file":"tests/hermes_cli/test_secrets_token_rotation.py","hunks":["@@ -0,0 +1,180 @@\n+\"\"\"Tests for `hermes secrets bitwarden token` / `hermes secrets onepassword token`.\n+\n+The rotation command must: verify the candidate token BEFORE persisting,\n+never touch .env on a rejected token, store + clear caches on success,\n+and fail cleanly without a TTY.\n+\"\"\"\n+from __future__ import annotations\n+\n+import argparse\n+from pathlib import Path\n+from unittest import mock\n+\n+import pytest\n+\n+from hermes_cli import onepassword_secrets_cli as op_cli\n+from hermes_cli import secrets_cli as bw_cli\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bitwarden\n+# ---------------------------------------------------------------------------\n+\n+\n+def _bw_args(**overrides):\n+    return argparse.Namespace(\n+        access_token=overrides.get(\"access_token\", \"\"),\n+        no_verify=overrides.get(\"no_verify\", False),\n+    )\n+\n+\n[redacted:email]\n+def bw_env(monkeypatch, tmp_path):\n+    saved = {}\n+    monkeypatch.setattr(bw_cli, \"load_config\", lambda: {\n+        \"secrets\": {\"bitwarden\": {\n+            \"enabled\": True,\n+            \"access_token_env\": \"BWS_ACCESS_TOKEN\",\n+            \"project_id\": \"proj-1\",\n+            \"server_url\": \"\",\n+        }},\n+    })\n+    monkeypatch.setattr(\n+        bw_cli, \"save_env_value\",\n+        lambda name, value: saved.__setitem__(name, value),\n+    )\n+    monkeypatch.setattr(bw_cli, \"get_env_path\", lambda: tmp_path / \".env\")\n+    monkeypatch.setattr(\n+        bw_cli.bw, \"find_bws\",\n+        lambda install_if_missing=True: Path(\"/fake/bws\"),\n+    )\n+    return saved\n+\n+\n+def test_bw_token_rejected_token_never_persisted(bw_env, monkeypatch):\n+    monkeypatch.setattr(\n+        bw_cli, \"_list_projects\",\n+        lambda binary, token, console, server_url=\"\": None,  # probe fails\n+    )\n+    rc = bw_cli.cmd_token(_bw_args(access_token=\"0.bad\"))\n+    assert rc == 1\n+    assert bw_env == {}  # nothing written to .env\n+\n+\n+def test_bw_token_accepted_token_persisted_and_caches_cleared(bw_env, monkeypatch):\n+    cleared = []\n+    monkeypatch.setattr(\n+        bw_cli, \"_list_projects\",\n+        lambda binary, token, console, server_url=\"\": [{\"id\": \"proj-1\"}],\n+    )\n+    monkeypatch.setattr(bw_cli.bw, \"clear_caches\", lambda *a, **kw: cleared.append(True))\n+    rc = bw_cli.cmd_token(_bw_args(access_token=\"[redacted:secret-assignment]\"))\n+    assert rc == 0\n+    assert bw_env == {\"BWS_ACCESS_TOKEN\": \"[redacted:secret-assignment]\"}\n+    assert cleared\n+\n+\n+def test_bw_token_warns_when_project_not_visible(bw_env, monkeypatch, capsys):\n+    monkeypatch.setattr(\n+        bw_cli, \"_list_projects\",\n+        lambda binary, token, console, server_url=\"\": [{\"id\": \"other-proj\"}],\n+    )\n+    monkeypatch.setattr(bw_cli.bw, \"clear_caches\", lambda *a, **kw: None)\n+    rc = bw_cli.cmd_token(_bw_args(access_token=\"[redacted:secret-assignment]\"))\n+    assert rc == 0  # stored anyway — the token itself is valid\n+    out = capsys.readouterr().out\n+    assert \"proj-1\" in out and \"not visible\" in out\n+\n+\n+def test_bw_token_no_verify_skips_probe(bw_env, monkeypatch):\n+    probe = mock.Mock()\n+    monkeypatch.setattr(bw_cli, \"_list_projects\", probe)\n+    monkeypatch.setattr(bw_cli.bw, \"clear_caches\", lambda *a, **kw: None)\n+    rc = bw_cli.cmd_token(_bw_args(access_token=\"0.x\", no_verify=True))\n+    assert rc == 0\n+    probe.assert_not_called()\n+    assert bw_env == {\"BWS_ACCESS_TOKEN\": \"0.x\"}\n+\n+\n+def test_bw_token_non_tty_requires_flag(bw_env, monkeypatch):\n+    monkeypatch.setattr(\"sys.stdin.isatty\", lambda: False)\n+    rc = bw_cli.cmd_token(_bw_args())\n+    assert rc == 1\n+    assert bw_env == {}\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1Password\n+# ---------------------------------------------------------------------------\n+\n+\n+def _op_args(**overrides):\n+    return argparse.Namespace(\n+        token=overrides.get(\"token\", \"\"),\n+        no_verify=overrides.get(\"no_verify\", False),\n+    )\n+\n+\n[redacted:email]\n+def op_env(monkeypatch, tmp_path):\n+    saved = {}\n+    monkeypatch.setattr(op_cli, \"load_config\", lambda: {\n+        \"secrets\": {\"onepassword\": {\n+            \"enabled\": True,\n+            \"service_account_token_env\": \"OP_SERVICE_ACCOUNT_TOKEN\",\n+        }},\n+    })\n+    monkeypatch.setattr(\n+        op_cli, \"save_env_value\",\n+        lambda name, value: saved.__setitem__(name, value),\n+    )\n+    monkeypatch.setattr(op_cli, \"get_env_path\", lambda: tmp_path / \".env\")\n+    monkeypatch.setattr(\n+        op_cli.op_src, \"find_op\", lambda binary_path=\"\": Path(\"/fake/op\")\n+    )\n+    return saved\n+\n+\n+def test_op_token_rejected_never_persisted(op_env, monkeypatch):\n+    monkeypatch.setattr(\n+        op_cli, \"_op_whoami\",\n+        lambda binary, account, token_value=\"\": None,\n+    )\n+    rc = op_cli.cmd_token(_op_args(token=\"[redacted:secret-assignment]\"))\n+    assert rc == 1\n+    assert op_env == {}\n+\n+\n+def test_op_token_accepted_persisted_and_caches_cleared(op_env, monkeypatch):\n+    cleared = []\n+    monkeypatch.setattr(\n+        op_cli, \"_op_whoami\",\n+        lambda binary, account, token_value=\"\": \"service-account test\",\n+    )\n+    monkeypatch.setattr(\n+        op_cli.op_src, \"clear_caches\", lambda *a, **kw: cleared.append(True)\n+    )\n+    rc = op_cli.cmd_token(_op_args(token=\"[redacted:secret-assignment]\"))\n+    assert rc == 0\n+    assert op_env == {\"OP_SERVICE_ACCOUNT_TOKEN\": \"[redacted:secret-assignment]\"}\n+    assert cleared\n+\n+\n+def test_op_token_probe_uses_candidate_token(op_env, monkeypatch):\n+    seen = {}\n+\n+    def fake_whoami(binary, account, token_value=\"\"):\n+        seen[\"token\"] = token_value\n+        return \"ok\"\n+\n+    monkeypatch.setattr(op_cli, \"_op_whoami\", fake_whoami)\n+    monkeypatch.setattr(op_cli.op_src, \"clear_caches\", lambda *a, **kw: None)\n+    op_cli.cmd_token(_op_args(token=\"[redacted:secret-assignment]\"))\n+    assert seen[\"token\"] == \"ops_candidate\"\n+\n+\n+def test_op_token_non_tty_requires_flag(op_env, monkeypatch):\n+    monkeypatch.setattr(\"sys.stdin.isatty\", lambda: False)\n+    rc = op_cli.cmd_token(_op_args())\n+    assert rc == 1\n+    assert op_env == {}"]},{"file":"tests/secret_sources/test_error_remediation.py","hunks":["@@ -0,0 +1,232 @@\n+\"\"\"Error remediation for secret sources.\n+\n+Covers the ErrorKind classification of Bitwarden's `invalid_client`\n+identity reject, the bws stderr summarizer, the per-source\n+``remediation()`` hook, and the env_loader startup hint printer.\n+\"\"\"\n+from __future__ import annotations\n+\n+from pathlib import Path\n+from unittest import mock\n+\n+import pytest\n+\n+from agent.secret_sources import bitwarden as bw\n+from agent.secret_sources import onepassword as op\n+from agent.secret_sources.base import ErrorKind, SecretSource\n+from agent.secret_sources.bitwarden import (\n+    BitwardenSource,\n+    _classify_bws_error,\n+    _summarize_bws_stderr,\n+)\n+from agent.secret_sources.onepassword import OnePasswordSource\n+\n+\n+_BWS_INVALID_CLIENT_DUMP = \"\"\"\\\n+Error:\n+   0: Received error message from server: [400 Bad Request] {\"error\":\"invalid_client\"}\n+\n+Location:\n+   crates/bws/src/main.rs:108\n+\n+Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.\n+Run with RUST_BACKTRACE=full to include source snippets.\n+\"\"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _summarize_bws_stderr\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_summarize_strips_rust_report_noise():\n+    summary = _summarize_bws_stderr(_BWS_INVALID_CLIENT_DUMP)\n+    assert \"invalid_client\" in summary\n+    assert \"Location:\" not in summary\n+    assert \"main.rs\" not in summary\n+    assert \"Backtrace\" not in summary\n+    assert \"Error:\" not in summary\n+\n+\n+def test_summarize_joins_multiple_cause_lines():\n+    raw = \"Error:\\n   0: outer cause\\n   1: inner cause\\n\\nLocation:\\n   x.rs:1\"\n+    assert _summarize_bws_stderr(raw) == \"outer cause; inner cause\"\n+\n+\n+def test_summarize_falls_back_to_raw_on_unknown_shape():\n+    assert _summarize_bws_stderr(\"plain failure text\") == \"plain failure text\"\n+    assert _summarize_bws_stderr(\"\") == \"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _classify_bws_error — the invalid_client identity reject is an auth failure\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email](\"message\", [\n+    'bws exited 1: Received error message from server: [400 Bad Request] {\"error\":\"invalid_client\"}',\n+    \"invalid_grant returned by identity\",\n+    \"server said 401 unauthorized\",\n+])\n+def test_classify_auth_failures(message):\n+    assert _classify_bws_error(message) == ErrorKind.AUTH_FAILED\n+\n+\n+def test_classify_unknown_stays_internal():\n+    assert _classify_bws_error(\"some novel explosion\") == ErrorKind.INTERNAL\n+\n+\n+# ---------------------------------------------------------------------------\n+# BitwardenSource.fetch — auth failures get a human explanation\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_fetch_auth_failure_gets_friendly_error(monkeypatch, tmp_path):\n+    src = BitwardenSource()\n+    monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.dead\")\n+    monkeypatch.setattr(bw, \"find_bws\", lambda install_if_missing=True: tmp_path / \"bws\")\n+\n+    def boom(**kwargs):\n+        raise RuntimeError(\n+            'bws exited 1: Received error message from server: '\n+            '[400 Bad Request] {\"error\":\"invalid_client\"}'\n+        )\n+\n+    monkeypatch.setattr(bw, \"fetch_bitwarden_secrets\", boom)\n+    result = src.fetch({\"enabled\": True, \"project_id\": \"p\"}, tmp_path)\n+    assert result.error_kind == ErrorKind.AUTH_FAILED\n+    assert \"revoked, expired\" in result.error\n+    assert \"BWS_ACCESS_TOKEN\" in result.error\n+    assert \"invalid_client\" in result.error  # mechanics preserved\n+\n+\n+# ---------------------------------------------------------------------------\n+# remediation() hook\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_bitwarden_auth_remediation_points_at_token_command():\n+    hint = BitwardenSource().remediation(ErrorKind.AUTH_FAILED, {})\n+    assert \"hermes secrets bitwarden token\" in hint\n+\n+\n+def test_onepassword_auth_remediation_points_at_token_command():\n+    hint = OnePasswordSource().remediation(ErrorKind.AUTH_FAILED, {})\n+    assert \"hermes secrets onepassword token\" in hint\n+    assert \"OP_SERVICE_ACCOUNT_TOKEN\" in hint\n+\n+\n+def test_onepassword_remediation_uses_configured_token_env():\n+    hint = OnePasswordSource().remediation(\n+        ErrorKind.AUTH_FAILED, {\"service_account_token_env\": \"MY_OP_TOKEN\"}\n+    )\n+    assert \"MY_OP_TOKEN\" in hint\n+\n+\n+def test_base_remediation_covers_common_kinds():\n+    class _Src(SecretSource):\n+        name = \"dummy\"\n+        label = \"Dummy\"\n+\n+        def fetch(self, cfg, home_path):  # pragma: no cover\n+            raise NotImplementedError\n+\n+    src = _Src()\n+    for kind in (ErrorKind.NOT_CONFIGURED, ErrorKind.BINARY_MISSING,\n+                 ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED,\n+                 ErrorKind.NETWORK, ErrorKind.TIMEOUT):\n+        hint = src.remediation(kind, {})\n+        assert hint, f\"no default hint for {kind}\"\n+        if kind in (ErrorKind.NOT_CONFIGURED, ErrorKind.BINARY_MISSING,\n+                    ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):\n+            assert \"hermes secrets dummy\" in hint\n+    # Kinds without a sensible generic action stay silent.\n+    assert _Src().remediation(ErrorKind.INTERNAL, {}) == \"\"\n+    assert _Src().remediation(None, {}) == \"\"\n+\n+\n+def test_remediation_never_raises_on_junk_cfg():\n+    for cfg in (None, [], \"nope\", 42):\n+        assert isinstance(BitwardenSource().remediation(ErrorKind.AUTH_FAILED, cfg), str)\n+        assert isinstance(OnePasswordSource().remediation(ErrorKind.AUTH_FAILED, cfg), str)\n+\n+\n+# ---------------------------------------------------------------------------\n+# env_loader startup hint\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_env_loader_prints_remediation_hint(tmp_path, monkeypatch, capsys):\n+    from hermes_cli import env_loader\n+    from agent.secret_sources import registry\n+\n+    registry._reset_registry_for_tests()\n+    env_loader.reset_secret_source_cache()\n+\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \"config.yaml\").write_text(\n+        \"secrets:\\n\"\n+        \"  bitwarden:\\n\"\n+        \"    enabled: true\\n\"\n+        \"    project_id: proj\\n\"\n+    )\n+    monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.dead\")\n+    monkeypatch.setattr(bw, \"find_bws\", lambda install_if_missing=True: tmp_path / \"bws\")\n+\n+    def boom(**kwargs):\n+        raise RuntimeError(\n+            'bws exited 1: Received error message from server: '\n+            '[400 Bad Request] {\"error\":\"invalid_client\"}'\n+        )\n+\n+    monkeypatch.setattr(bw, \"fetch_bitwarden_secrets\", boom)\n+    try:\n+        env_loader._apply_external_secret_sources(home)\n+    finally:\n+        registry._reset_registry_for_tests()\n+        env_loader.reset_secret_source_cache()\n+\n+    err = capsys.readouterr().err\n+    assert \"rejected the machine-account access token\" in err\n+    assert \"hermes secrets bitwarden token\" in err\n+\n+\n+def test_env_loader_hint_survives_broken_remediation(tmp_path, monkeypatch, capsys):\n+    \"\"\"A plugin source whose remediation() raises must not break startup.\"\"\"\n+    from hermes_cli import env_loader\n+    from agent.secret_sources import registry\n+\n+    class _Broken(SecretSource):\n+        name = \"brokensrc\"\n+        label = \"Broken\"\n+        shape = \"bulk\"\n+\n+        def fetch(self, cfg, home_path):\n+            from agent.secret_sources.base import FetchResult\n+            res = FetchResult()\n+            res.error = \"kaput\"\n+            res.error_kind = ErrorKind.AUTH_FAILED\n+            return res\n+\n+        def remediation(self, kind, cfg):\n+            raise RuntimeError(\"hint machine broke\")\n+\n+    registry._reset_registry_for_tests()\n+    registry._BUILTINS_LOADED = True  # keep real builtins out of this test\n+    registry.register_source(_Broken())\n+    env_loader.reset_secret_source_cache()\n+\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \"config.yaml\").write_text(\n+        \"secrets:\\n  brokensrc:\\n    enabled: true\\n\"\n+    )\n+    try:\n+        env_loader._apply_external_secret_sources(home)\n+    finally:\n+        registry._reset_registry_for_tests()\n+        env_loader.reset_secret_source_cache()\n+\n+    err = capsys.readouterr().err\n+    assert \"kaput\" in err  # error still surfaced, no crash"]},{"file":"website/docs/developer-guide/secret-source-plugin.md","hunks":["@@ -110,6 +110,7 @@ class MyVaultSource(SecretSource):\n | `protected_env_vars(cfg)` | empty | You have a bootstrap token (you almost certainly do) |\n | `fetch_timeout_seconds(cfg)` | 120s | Your backend needs a different budget |\n | `config_schema()` | `{}` | Declare config keys for setup surfaces |\n+| `remediation(kind, cfg)` | generic per-`ErrorKind` hints | You want failure warnings to point at your own fix-it command (e.g. the bundled sources return `Run hermes secrets <name> token…` for `AUTH_FAILED`). Must be a pure kind→string mapping: no I/O, never raises. Return `\"\"` to suppress the hint. |\n \n ## Subprocess safety: use `run_secret_cli()`\n "]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -432,6 +432,7 @@ Pull API keys from an external secret manager at process startup instead of stor\n |------------|-------------|\n | `setup` | Interactive wizard: install the pinned `bws` binary, store an access token, and pick a project. Accepts `--project-id`, `--access-token`, and `--server-url` for non-interactive use. |\n | `status` | Show current config, binary path/version, and last fetch info. |\n+| `token` | Rotate the access token: validates the new token against Bitwarden before storing it in `.env` (a rejected token changes nothing). Accepts `--access-token` for non-interactive use and `--no-verify` to skip the probe. |\n | `sync` | Fetch secrets now and report what changed. Add `--apply` to actually export the secrets into the current shell's environment (default is dry-run). |\n | `install` | Download and verify the pinned `bws` binary. `--force` re-downloads even if a managed copy already exists. |\n | `disable` | Turn off the Bitwarden integration. |"]},{"file":"website/docs/user-guide/secrets/bitwarden.md","hunks":["@@ -69,11 +69,30 @@ From now on, every `hermes` invocation pulls fresh secrets at startup. You'll se\n |---|---|\n | `hermes secrets bitwarden setup` | Interactive wizard (install binary, prompt for token, pick project, test fetch) |\n | `hermes secrets bitwarden status` | Show config + binary version + token presence |\n+| `hermes secrets bitwarden token` | Rotate the access token: validate the new token against Bitwarden, then store it in `.env` |\n | `hermes secrets bitwarden sync` | Dry-run: pull secrets now and show what would be applied |\n | `hermes secrets bitwarden sync --apply` | Pull and export into the current shell's environment |\n | `hermes secrets bitwarden install` | Just download the pinned `bws` binary (no auth required) |\n | `hermes secrets bitwarden disable` | Flip `enabled: false`; leaves token + project id in place |\n \n+## Rotating an expired or revoked token\n+\n+When the machine-account token expires, gets revoked, or the account is deleted, startup shows:\n+\n+```\n+Bitwarden Secrets Manager: Bitwarden rejected the machine-account access token (BWS_ACCESS_TOKEN) — it was likely revoked, expired, or belongs to another region.  (...)\n+Bitwarden Secrets Manager: → Run `hermes secrets bitwarden token` to paste a fresh access token ...\n+```\n+\n+Fix it without re-running the whole wizard:\n+\n+```bash\n+hermes secrets bitwarden token                     # masked prompt\n+hermes secrets bitwarden token --access-token 0.…  # non-interactive\n+```\n+\n+The command probes Bitwarden with the new token **before** writing anything — a rejected token leaves your current `.env` untouched. On success it stores the token, clears the fetch caches, and warns if the configured project is not visible to the new machine account.\n+\n ## Configuration\n \n Defaults in `~/.hermes/config.yaml`:","@@ -107,12 +126,14 @@ Bitwarden never blocks Hermes startup. If anything goes wrong, you'll see a one-\n | Symptom | Cause | Fix |\n |---|---|---|\n | `BWS_ACCESS_TOKEN is not set` | Enabled in config but token cleared from `.env` | Re-run `hermes secrets bitwarden setup` |\n-| `bws exited 1: invalid access token` | Token revoked or wrong | Generate a new token, re-run setup |\n-| `[400 Bad Request] {\"error\":\"invalid_client\"}` | Token is for a Bitwarden region other than the one `bws` is calling (e.g. EU token hitting the US identity endpoint) | Re-run setup and pick the right region, or set `secrets.bitwarden.server_url` to `https://vault.bitwarden.eu` (or your self-hosted URL) |\n+| `Bitwarden rejected the machine-account access token … invalid_client` | Token revoked, expired, machine account deleted — or the token belongs to another region (e.g. EU token hitting the US identity endpoint) | Run `hermes secrets bitwarden token` to paste a fresh token; for region mismatches re-run setup and pick EU/self-hosted (or set `secrets.bitwarden.server_url`) |\n+| `bws exited 1: invalid access token` | Token revoked or wrong | Run `hermes secrets bitwarden token` with a new token |\n | `bws timed out` | Network blocked or Bitwarden API slow | Check connectivity to `api.bitwarden.com` (or your `server_url`) |\n | `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 |\n | `Checksum mismatch` | Download corrupted or tampered | Re-run, will retry; if it persists, file an issue |\n \n+Startup warnings now include a `→` remediation line telling you exactly which command fixes the failure.\n+\n ## Security notes\n \n - 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."]},{"file":"website/docs/user-guide/secrets/onepassword.md","hunks":["@@ -93,6 +93,7 @@ From now on, every `hermes` invocation resolves the references at startup. You'l\n |---|---|\n | `hermes secrets onepassword setup` | Verify `op`, set account / token env var, enable |\n | `hermes secrets onepassword status` | Show config, binary, auth, and configured references |\n+| `hermes secrets onepassword token` | Rotate the service-account token: validate with `op whoami`, then store it in `.env` |\n | `hermes secrets onepassword set ENV_VAR \"op://…\"` | Map an env var to a reference (stored stripped + validated) |\n | `hermes secrets onepassword remove ENV_VAR` | Drop a mapping |\n | `hermes secrets onepassword sync` | Dry-run: resolve references now and show what would apply |","@@ -136,11 +137,13 @@ secrets:\n | Symptom | Cause | Fix |\n |---|---|---|\n | `the op CLI was not found on PATH` | `op` not installed / not on PATH | Install the CLI, or set `secrets.onepassword.binary_path` |\n-| `op read failed for 'op://…': …` | Locked session, expired token, or no vault access | `op signin`, refresh the token, or grant the service account access |\n+| `op read failed for 'op://…': …` | Locked session, expired token, or no vault access | `op signin`, run `hermes secrets onepassword token` to rotate the service-account token, or grant the service account access |\n | `op read returned an empty value for 'op://…'` | The referenced field exists but is empty | Fix the item/field in 1Password (an empty value is never applied — your existing env var is left intact) |\n | `… is not an op:// secret reference` | A mapping value isn't an `op://` reference | Re-set it with the correct `op://vault/item/field` form |\n | `op read timed out` | Network blocked or 1Password slow | Check connectivity / the desktop app integration |\n \n+Startup warnings now include a `→` remediation line telling you exactly which command fixes the failure.\n+\n ## Caching\n \n Successful, complete pulls are cached in-process and on disk under `<hermes_home>/cache/op_cache.json` (written atomically, mode `0600`), so back-to-back short-lived `hermes` invocations don't re-shell `op` for every reference. The cache:"]},{"file":"website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md","hunks":["@@ -69,11 +69,23 @@ hermes secrets bitwarden status\n |---|---|\n | `hermes secrets bitwarden setup` | 交互式向导（安装二进制文件、提示输入令牌、选择项目、测试拉取） |\n | `hermes secrets bitwarden status` | 显示配置、二进制版本及令牌是否存在 |\n+| `hermes secrets bitwarden token` | 轮换访问令牌：先向 Bitwarden 验证新令牌，验证通过后再写入 `.env` |\n | `hermes secrets bitwarden sync` | 演习模式：立即拉取 secret 并显示将应用的内容 |\n | `hermes secrets bitwarden sync --apply` | 拉取并导出到当前 shell 的环境中 |\n | `hermes secrets bitwarden install` | 仅下载固定版本的 `bws` 二进制文件（无需认证） |\n | `hermes secrets bitwarden disable` | 将 `enabled` 设为 `false`；保留令牌和项目 ID |\n \n+## 轮换已过期或已吊销的令牌\n+\n+当机器账户令牌过期、被吊销或账户被删除时，启动信息会显示令牌被拒绝的说明，并附带 `→` 修复提示。无需重新运行整个向导即可修复：\n+\n+```bash\n+hermes secrets bitwarden token                     # 隐藏输入提示\n+hermes secrets bitwarden token --access-token 0.…  # 非交互式\n+```\n+\n+该命令会在写入任何内容**之前**用新令牌探测 Bitwarden——令牌被拒绝时不会改动现有 `.env`。成功后会存储令牌、清除拉取缓存，并在配置的项目对新机器账户不可见时发出警告。\n+\n ## 配置\n \n `~/.hermes/config.yaml` 中的默认值：","@@ -107,12 +119,14 @@ Bitwarden 永远不会阻塞 Hermes 启动。如果出现任何问题，stderr \n | 现象 | 原因 | 修复方法 |\n |---|---|---|\n | `BWS_ACCESS_TOKEN is not set` | 配置中已启用，但令牌已从 `.env` 中清除 | 重新运行 `hermes secrets bitwarden setup` |\n-| `bws exited 1: invalid access token` | 令牌已吊销或有误 | 生成新令牌，重新运行 setup |\n-| `[400 Bad Request] {\"error\":\"invalid_client\"}` | 令牌所属的 Bitwarden 区域与 `bws` 调用的区域不匹配（例如欧盟令牌访问了美国 identity 端点） | 重新运行 setup 并选择正确区域，或将 `secrets.bitwarden.server_url` 设为 `https://vault.bitwarden.eu`（或自托管 URL） |\n+| `Bitwarden rejected the machine-account access token … invalid_client` | 令牌已吊销、过期、机器账户被删除——或令牌属于其他区域（例如欧盟令牌访问了美国 identity 端点） | 运行 `hermes secrets bitwarden token` 粘贴新令牌；区域不匹配时重新运行 setup 选择欧盟/自托管（或设置 `secrets.bitwarden.server_url`） |\n+| `bws exited 1: invalid access token` | 令牌已吊销或有误 | 运行 `hermes secrets bitwarden token` 提供新令牌 |\n | `bws timed out` | 网络受阻或 Bitwarden API 响应缓慢 | 检查到 `api.bitwarden.com`（或你的 `server_url`）的连通性 |\n | `bws binary not available` | `auto_install: false` 且 `bws` 不在 PATH 中 | 从 [github.com/bitwarden/sdk-sm/releases](https://github.com/bitwarden/sdk-sm/releases) 手动安装，或重新开启 `auto_install` |\n | `Checksum mismatch` | 下载内容损坏或被篡改 | 重新运行，将自动重试；如持续出现，请提交 issue |\n \n+启动警告现在会附带一行 `→` 修复提示，直接告诉你运行哪条命令即可修复。\n+\n ## 安全说明\n \n - 引导令牌（`BWS_ACCESS_TOKEN`）本身是敏感信息——任何持有它的人都可以读取机器账户有权访问的所有 secret。请与其他 API 密钥同等对待。\n"]}]},{"kind":"pr","number":69055,"title":"fix(secrets): validate the Bitwarden token in status output (salvage #40290)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/secrets_cli.py","tests/hermes_cli/test_bitwarden_status.py","website/docs/reference/cli-commands.md","website/docs/user-guide/secrets/bitwarden.md","website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}