From 89040e0db3cc3a76980e9e7dc2f045a267f4ebb0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:36:40 -0700 Subject: [PATCH] fix(secrets): fail early with clear error when bitwarden setup runs without TTY (#40571) Salvaged from #40280; cleaned up, re-verified against main, tests added. Co-authored-by: liuhao1024 --- hermes_cli/secrets_cli.py | 24 +++ .../test_secrets_bitwarden_non_tty.py | 159 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/hermes_cli/test_secrets_bitwarden_non_tty.py diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index 5ef8b15aef2..cc31cb33160 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -14,6 +14,7 @@ import argparse import json import os import subprocess +import sys from pathlib import Path from typing import List, Optional @@ -129,6 +130,29 @@ def cmd_setup(args: argparse.Namespace) -> int: ) return 1 + # -- non-interactive guard -- + if not sys.stdin.isatty(): + missing = [] + if not (args.access_token and args.access_token.strip()): + missing.append("--access-token") + if not (args.server_url and args.server_url.strip()): + # Also accept BWS_SERVER_URL env var as non-interactive substitute + if not os.environ.get("BWS_SERVER_URL", "").strip(): + missing.append("--server-url") + if not (args.project_id and args.project_id.strip()): + missing.append("--project-id") + if missing: + console.print( + f" [red]Non-interactive mode (no TTY) requires all setup flags.[/red]\n" + f" Missing: {', '.join(missing)}\n\n" + " Usage:\n" + " hermes secrets bitwarden setup \\\n" + " --access-token '0.xxx' \\\n" + " --server-url 'https://vault.bitwarden.com' \\\n" + " --project-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'" + ) + return 1 + # ------------------------------------------------------------------- token console.print() console.print("[bold]Step 2[/bold] Provide your access token") diff --git a/tests/hermes_cli/test_secrets_bitwarden_non_tty.py b/tests/hermes_cli/test_secrets_bitwarden_non_tty.py new file mode 100644 index 00000000000..65329ca802b --- /dev/null +++ b/tests/hermes_cli/test_secrets_bitwarden_non_tty.py @@ -0,0 +1,159 @@ +"""Regression tests for hermes secrets bitwarden setup non-TTY guard. + +Issue #40274: cmd_setup() crashes with EOFError when stdin is not a TTY +because getpass.getpass() and console.input() require an interactive terminal. +""" +from __future__ import annotations + +import argparse +from unittest.mock import patch + +import pytest + + +class TestCmdSetupNonTtyGuard: + """cmd_setup should fail early with a clear error in non-TTY environments.""" + + @staticmethod + def _make_args(**overrides): + ns = argparse.Namespace( + access_token=overrides.get("access_token", ""), + server_url=overrides.get("server_url", ""), + project_id=overrides.get("project_id", ""), + ) + return ns + + def test_missing_all_flags_returns_1(self, monkeypatch, capsys): + """Non-TTY with no flags → exit 1 with missing flags listed.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.find_bws", lambda install_if_missing=False: "/usr/bin/bws" + ) + monkeypatch.setattr( + "hermes_cli.secrets_cli._bws_version", lambda _: "2.0.0" + ) + + from hermes_cli.secrets_cli import cmd_setup + + result = cmd_setup(self._make_args()) + assert result == 1 + captured = capsys.readouterr() + assert "Non-interactive mode" in captured.out + assert "--access-token" in captured.out + assert "--server-url" in captured.out + assert "--project-id" in captured.out + + def test_missing_access_token_only(self, monkeypatch, capsys): + """Non-TTY with server-url and project-id but no token → reports --access-token.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.find_bws", lambda install_if_missing=False: "/usr/bin/bws" + ) + monkeypatch.setattr( + "hermes_cli.secrets_cli._bws_version", lambda _: "2.0.0" + ) + + from hermes_cli.secrets_cli import cmd_setup + + result = cmd_setup(self._make_args( + server_url="https://vault.bitwarden.com", + project_id="aaaa-bbbb", + )) + assert result == 1 + captured = capsys.readouterr() + # The "Missing:" line should list --access-token only + assert "Missing:" in captured.out + assert "--access-token" in captured.out + # The usage example contains --server-url and --project-id, so check + # the missing line specifically: it should NOT list them as missing + missing_line = [l for l in captured.out.split("\n") if "Missing:" in l][0] + assert "--access-token" in missing_line + assert "--server-url" not in missing_line + assert "--project-id" not in missing_line + + def test_missing_server_url_with_env_var_passes(self, monkeypatch): + """Non-TTY with BWS_SERVER_URL env set → server-url not required.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setenv("BWS_SERVER_URL", "https://vault.bitwarden.com") + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.find_bws", lambda install_if_missing=False: "/usr/bin/bws" + ) + monkeypatch.setattr( + "hermes_cli.secrets_cli._bws_version", lambda _: "2.0.0" + ) + monkeypatch.setattr("hermes_cli.secrets_cli.load_config", lambda: {}) + monkeypatch.setattr("hermes_cli.secrets_cli.save_env_value", lambda *a: None) + monkeypatch.setattr("hermes_cli.secrets_cli.get_env_path", lambda: "/tmp/.env") + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.fetch_bitwarden_secrets", + lambda **kw: ({"KEY": "val"}, []), + ) + + from hermes_cli.secrets_cli import cmd_setup + + result = cmd_setup(self._make_args( + access_token="0.valid-token", + project_id="aaaa-bbbb", + )) + assert result == 0 + + def test_all_flags_provided_passes_guard(self, monkeypatch): + """Non-TTY with all three flags → guard passes, proceeds to setup.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.find_bws", lambda install_if_missing=False: "/usr/bin/bws" + ) + monkeypatch.setattr( + "hermes_cli.secrets_cli._bws_version", lambda _: "2.0.0" + ) + monkeypatch.setattr("hermes_cli.secrets_cli.load_config", lambda: {}) + monkeypatch.setattr("hermes_cli.secrets_cli.save_env_value", lambda *a: None) + monkeypatch.setattr("hermes_cli.secrets_cli.get_env_path", lambda: "/tmp/.env") + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.fetch_bitwarden_secrets", + lambda **kw: ({"KEY": "val"}, []), + ) + + from hermes_cli.secrets_cli import cmd_setup + + result = cmd_setup(self._make_args( + access_token="0.valid-token", + server_url="https://vault.bitwarden.com", + project_id="aaaa-bbbb", + )) + assert result == 0 + + def test_tty_does_not_trigger_guard(self, monkeypatch): + """With TTY, the guard should not trigger (interactive mode allowed).""" + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.find_bws", lambda install_if_missing=False: "/usr/bin/bws" + ) + monkeypatch.setattr( + "hermes_cli.secrets_cli._bws_version", lambda _: "2.0.0" + ) + monkeypatch.setattr( + "hermes_cli.secrets_cli.masked_secret_prompt", lambda prompt: "0.valid-token" + ) + monkeypatch.setattr("hermes_cli.secrets_cli.load_config", lambda: {}) + monkeypatch.setattr("hermes_cli.secrets_cli.save_env_value", lambda *a: None) + monkeypatch.setattr("hermes_cli.secrets_cli.get_env_path", lambda: "/tmp/.env") + monkeypatch.setattr( + "hermes_cli.secrets_cli._resolve_server_url", + lambda *a: "https://vault.bitwarden.com", + ) + # Provide project_id directly to avoid interactive project prompt + monkeypatch.setattr( + "hermes_cli.secrets_cli.bw.fetch_bitwarden_secrets", + lambda **kw: ({"KEY": "val"}, []), + ) + + from hermes_cli.secrets_cli import cmd_setup + + # With TTY + all flags → should complete without hitting guard + result = cmd_setup(self._make_args( + access_token="0.valid-token", + server_url="https://vault.bitwarden.com", + project_id="aaaa-bbbb", + )) + assert result == 0