fix(cli): show masked feedback for secret prompts

This commit is contained in:
helix4u 2026-05-24 17:15:03 -06:00 committed by Teknium
parent d952b377aa
commit ec4d6f1823
20 changed files with 310 additions and 61 deletions

View file

@ -534,7 +534,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
# then display name. The api_mode prompt also runs before model selection.
answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": next(answers))
hermes_main._model_flow_custom({})
output = capsys.readouterr().out
@ -592,7 +592,7 @@ def test_model_flow_custom_persists_selected_api_mode(monkeypatch):
]
)
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("getpass.getpass", lambda _prompt="": "test-key")
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": "test-key")
hermes_main._model_flow_custom({"model": {"provider": "custom"}})

View file

@ -83,10 +83,10 @@ def test_cancel_secret_capture_marks_setup_skipped():
assert cli._secret_deadline == 0
def test_secret_capture_uses_getpass_without_tui():
def test_secret_capture_uses_masked_prompt_without_tui():
cli = _make_cli_stub()
with patch("hermes_cli.callbacks.getpass.getpass", return_value="secret-value"), patch(
with patch("hermes_cli.callbacks.masked_secret_prompt", return_value="secret-value"), patch(
"hermes_cli.callbacks.save_env_value_secure"
) as save_secret:
save_secret.return_value = {

View file

@ -54,7 +54,7 @@ class TestStaleOAuthTokenDetection:
# Simulate user types "3" (Cancel) when prompted for re-auth
monkeypatch.setattr("builtins.input", lambda _: "3")
monkeypatch.setattr("getpass.getpass", lambda _: "")
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "")
from hermes_cli.main import _model_flow_anthropic
cfg = {}

View file

@ -40,7 +40,10 @@ def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypa
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr("getpass.getpass", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr(
"hermes_cli.secret_prompt.masked_secret_prompt",
lambda _prompt="": "sk-ant-oat01-manual-token",
)
from hermes_cli.main import _run_anthropic_oauth_flow

View file

@ -0,0 +1,20 @@
from hermes_cli import cli_output
def test_password_prompt_uses_masked_secret_prompt(monkeypatch):
seen = {}
def fake_masked_secret_prompt(display):
seen["display"] = display
return " secret "
monkeypatch.setattr(cli_output, "masked_secret_prompt", fake_masked_secret_prompt)
assert cli_output.prompt("API key", default="old", password=True) == "secret"
assert "API key [old]" in seen["display"]
def test_empty_password_prompt_returns_default(monkeypatch):
monkeypatch.setattr(cli_output, "masked_secret_prompt", lambda _display: "")
assert cli_output.prompt("API key", default="old", password=True) == "old"

View file

@ -486,6 +486,49 @@ class TestOptionalEnvVarsRegistry:
assert "TAVILY_API_KEY" in all_vars
class TestConfigMigrationSecretPrompts:
def test_required_secret_env_prompt_uses_masked_prompt(self, tmp_path, monkeypatch):
from hermes_cli import config as cfg_mod
saved = {}
monkeypatch.setattr(cfg_mod, "sanitize_env_file", lambda: 0)
monkeypatch.setattr(cfg_mod, "check_config_version", lambda: (999, 999))
monkeypatch.setattr(cfg_mod, "get_missing_config_fields", lambda: [])
monkeypatch.setattr(cfg_mod, "get_missing_skill_config_vars", lambda: [])
monkeypatch.setattr(
cfg_mod,
"get_missing_env_vars",
lambda required_only=True: [
{
"name": "TEST_API_KEY",
"description": "Test key",
"prompt": "Test API key",
"password": True,
}
]
if required_only
else [],
)
def fake_masked_secret_prompt(prompt):
saved["prompt"] = prompt
return "secret"
monkeypatch.setattr(cfg_mod, "masked_secret_prompt", fake_masked_secret_prompt)
monkeypatch.setattr(
cfg_mod,
"save_env_value",
lambda name, value: saved.update({name: value}),
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
results = cfg_mod.migrate_config(interactive=True, quiet=True)
assert saved["prompt"] == " Test API key: "
assert saved["TEST_API_KEY"] == "secret"
assert results["env_added"] == ["TEST_API_KEY"]
class TestAnthropicTokenMigration:
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""

View file

@ -663,7 +663,7 @@ class TestPromptPluginEnvVars:
printed = " ".join(str(c) for c in console.print.call_args_list)
assert "langfuse.com" in printed
def test_secret_uses_getpass(self):
def test_secret_uses_masked_prompt(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
@ -674,11 +674,11 @@ class TestPromptPluginEnvVars:
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("getpass.getpass", return_value="s3cret") as mock_gp, \
patch("hermes_cli.plugins_cmd.masked_secret_prompt", return_value="s3cret") as mock_prompt, \
patch("hermes_cli.config.save_env_value"):
_prompt_plugin_env_vars(manifest, console)
mock_gp.assert_called_once()
mock_prompt.assert_called_once()
def test_empty_input_skips(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars

View file

@ -33,7 +33,7 @@ def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="
pconfig = _pconfig(pconfig_name)
with patch("builtins.input", return_value=choice), \
patch("getpass.getpass", return_value=new_key):
patch("hermes_cli.secret_prompt.masked_secret_prompt", return_value=new_key):
return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id)

View file

@ -0,0 +1,62 @@
import pytest
from hermes_cli.secret_prompt import _collect_masked_input, masked_secret_prompt
def _run_collect(chars: str):
output: list[str] = []
iterator = iter(chars)
def read_char() -> str:
return next(iterator, "")
def write(text: str) -> None:
output.append(text)
value = _collect_masked_input(
read_char,
write,
"API key: ",
)
return value, "".join(output)
def test_collect_masked_input_shows_feedback_without_echoing_secret():
value, output = _run_collect("secret\n")
assert value == "secret"
assert output == "API key: ******\n"
assert "secret" not in output
def test_collect_masked_input_handles_backspace():
value, output = _run_collect("sec\x7fret\r")
assert value == "seret"
assert output == "API key: ***\b \b***\n"
assert "secret" not in output
def test_collect_masked_input_raises_keyboard_interrupt():
output: list[str] = []
with pytest.raises(KeyboardInterrupt):
_collect_masked_input(
lambda: "\x03",
output.append,
"API key: ",
)
assert "".join(output) == "API key: \n"
def test_masked_secret_prompt_falls_back_to_getpass_for_non_tty(monkeypatch):
class NonTty:
def isatty(self):
return False
monkeypatch.setattr("sys.stdin", NonTty())
monkeypatch.setattr("sys.stdout", NonTty())
monkeypatch.setattr("getpass.getpass", lambda prompt: f"value from {prompt}")
assert masked_secret_prompt("API key: ") == "value from API key: "

View file

@ -14,7 +14,8 @@ def test_prompt_strips_bracketed_paste_markers(monkeypatch):
def test_password_prompt_strips_bracketed_paste_markers(monkeypatch):
monkeypatch.setattr(
"getpass.getpass",
setup_mod,
"masked_secret_prompt",
lambda _prompt="": "\x1b[200~secret-token\x1b[201~",
)