mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-07 08:02:23 +00:00
fix(cli): show masked feedback for secret prompts
This commit is contained in:
parent
d952b377aa
commit
ec4d6f1823
20 changed files with 310 additions and 61 deletions
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
20
tests/hermes_cli/test_cli_output.py
Normal file
20
tests/hermes_cli/test_cli_output.py
Normal 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"
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
62
tests/hermes_cli/test_secret_prompt.py
Normal file
62
tests/hermes_cli/test_secret_prompt.py
Normal 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: "
|
||||
|
|
@ -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~",
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue