API key input sanitization and replacement handling
NousResearch/hermes-agent · cluster 16394 · hermes cli
Data as of
5 open items. Fix merged in #20162. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #20162. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #20162. That is separate from the fix event. - Still open
- 2 issues and 3 PRs.
Next step: read #20162, then check whether your report is one of the 2 still open.
Read #20162Pick up a Lab taskThese issues and PRs address stripping ASCII control characters from API keys before saving, and adding a menu to replace or keep existing API keys during setup. Both concerns relate to validating and managing credential input in the CLI configuration flow.
Open issues2
Open PRs3
Fixes on record
-
#20162 fix(setup): offer Keep/Replace/Clear when API key already exists
files:
hermes_cli/main.py,tests/hermes_cli/test_prompt_api_key.pyShow the diff
-
hermes_cli/main.py@@ -3974,6 +3974,85 @@ def _model_flow_copilot_acp(config, current_model=""): print(f"Default model set to: {selected} (via {pconfig.name})") +def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple: + """Shared API-key entry point for ``hermes setup`` / ``hermes model``. + + Handles both first-time entry and the already-configured case. When a key + is already present, offers [K]eep / [R]eplace / [C]lear so the user can + recover from a malformed paste without editing ``~/.hermes/.env`` by hand. + + Returns ``(resolved_key, abort)``. ``abort=True`` means the caller should + ``return`` immediately — the user cancelled entry, declined to replace, or + cleared the key and is now unconfigured. + """ + import getpass + + from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER + from hermes_cli.config import save_env_value + + key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" + + def _prompt_new_key(*, allow_lmstudio_default: bool) -> str: + if provider_id == "lmstudio" and allow_lmstudio_default: + prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " + else: + prompt = f"{key_env} (or Enter to cancel): " + try: + entered = getpass.getpass(prompt).strip() + except (KeyboardInterrupt, EOFError): + print() + return "" + if not entered and provider_id == "lmstudio" and allow_lmstudio_default: + return LMSTUDIO_NOAUTH_PLACEHOLDER + return entered + + # First-time entry ──────────────────────────────────────────────────── + if not existing_key: + print(f"No {pconfig.name} API key configured.") + if not key_env: + return "", True + new_key = _prompt_new_key(allow_lmstudio_default=True) + if not new_key: + print("Cancelled.") + return "", True + save_env_value(key_env, new_key) + print("API key saved.") + print() + return new_key, False + + # Already configured — offer K / R / C ──────────────────────────────── + print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + if not key_env: + # Nothing we can rewrite; just acknowledge and move on. + print() + return existing_key, False + try: + choice = input(" [K]eep / [R]eplace / [C]lear (default K): ").strip().lower() + except (KeyboardInterrupt, EOFError): + print() + choice = "k" + + if choice.startswith("r"): + new_key = _prompt_new_key(allow_lmstudio_default=False) + if not new_key: + print(" No change.") + print() + return existing_key, False + save_env_value(key_env, new_key) + print(" API key updated.") + print() + return new_key, False + + if choice.startswith("c"): + save_env_value(key_env, "") + print(f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.") + return "", True + + # Keep (default, or any other input) + print() + return existing_key, False + + def _model_flow_kimi(config, current_model=""): """Kimi / Moonshot model selection with automatic endpoint routing. @@ -4008,26 +4087,9 @@ def _model_flow_kimi(config, current_model=""): if existing_key: break - if not existing_key: - print(f"No {pconfig.name} API key configured.") - if key_env: - try: - import getpass - - new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not new_key: - print("Cancelled.") - return - save_env_value(key_env, new_key) - existing_key = new_key - print("API key saved.") - print() - else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") - print() + existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + if abort: + return # Step 2: Auto-detect endpoint from key prefix is_coding_plan = existing_key.startswith("sk-kimi-") @@ -4128,25 +4190,9 @@ def _model_flow_stepfun(config, current_model=""): if existing_key: break - if not existing_key: - print(f"No {pconfig.name} API key configured.") - if key_env: - try: - import getpass - new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not new_key: - print("Cancelled.") - return - save_env_value(key_env, new_key) - existing_key = new_key - print("API key saved.") - print() - else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") - print() + existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + if abort: + return current_base = "" if base_url_env: @@ -4522,33 +4568,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): if existing_key: break - if not existing_key: - print(f"No {pconfig.name} API key configured.") - if key_env: - try: - import getpass - - if provider_id == "lmstudio": - prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): " - else: - prompt = f"{key_env} (or Enter to cancel): " - new_key = getpass.getpass(prompt).strip() - except (KeyboardInterrupt, EOFError): - print() - return - if not new_key: - if provider_id == "lmstudio": - new_key = LMSTUDIO_NOAUTH_PLACEHOLDER - else: - print("Cancelled.") - return - save_env_value(key_env, new_key) - existing_key = new_key - print("API key saved.") - print() - else: - print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") - print() + existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id) + if abort: + return # Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash) # are exhausted in a handful of agent turns, so refuse to wire up the -
tests/hermes_cli/test_prompt_api_key.py@@ -0,0 +1,157 @@ +"""Tests for ``_prompt_api_key`` — the shared Keep/Replace/Clear menu used by +``hermes setup`` / ``hermes model`` when an API key already exists in ``.env``. + +Regression coverage for #16394: the wizard used to silently skip the key prompt +when any value was present (even malformed junk), leaving users stuck. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + + [redacted:email] +def profile_env(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + (home / ".env").write_text("") + return home + + +def _pconfig(name="deepseek"): + from hermes_cli.auth import PROVIDER_REGISTRY + return PROVIDER_REGISTRY[name] + + +def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="deepseek"): + """Invoke _prompt_api_key with mocked input()/getpass() responses.""" + from hermes_cli import main as m + + pconfig = _pconfig(pconfig_name) + with patch("builtins.input", return_value=choice), \ + patch("getpass.getpass", return_value=new_key): + return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id) + + +# First-time entry ──────────────────────────────────────────────────────────── + +def test_first_time_save_new_key(profile_env): + from hermes_cli.config import get_env_value + + key, abort = _run_prompt(existing_key="", choice="", new_key="sk-abcdef") + assert key == "sk-abcdef" + assert abort is False + assert get_env_value("DEEPSEEK_API_KEY") == "sk-abcdef" + + +def test_first_time_cancelled(profile_env): + key, abort = _run_prompt(existing_key="", choice="", new_key="") + assert key == "" + assert abort is True + + +# Already configured — K / R / C ─────────────────────────────────────────────── + +def test_keep_default_empty_input(profile_env): + from hermes_cli.config import save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-existing") + + key, abort = _run_prompt(existing_key="sk-existing", choice="") + assert key == "sk-existing" + assert abort is False + + +def test_keep_letter_k(profile_env): + key, abort = _run_prompt(existing_key="sk-existing", choice="k") + assert key == "sk-existing" + assert abort is False + + +def test_keep_on_unrecognised_input(profile_env): + """Garbage input falls through to keep — never destroys the user's key.""" + key, abort = _run_prompt(existing_key="sk-existing", choice="xyz") + assert key == "sk-existing" + assert abort is False + + +def test_replace_saves_new_key(profile_env): + from hermes_cli.config import get_env_value, save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-malformed-junk") + + key, abort = _run_prompt( + existing_key="sk-malformed-junk", choice="r", new_key="sk-fresh" + ) + assert key == "sk-fresh" + assert abort is False + assert get_env_value("DEEPSEEK_API_KEY") == "sk-fresh" + + +def test_replace_cancelled_preserves_key(profile_env): + """Empty entry to the Replace prompt means cancel — keeps the old key intact.""" + from hermes_cli.config import get_env_value, save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-existing") + + key, abort = _run_prompt( + existing_key="sk-existing", choice="r", new_key="" + ) + assert key == "sk-existing" + assert abort is False + assert get_env_value("DEEPSEEK_API_KEY") == "sk-existing" + + +def test_clear_wipes_env_and_aborts(profile_env): + from hermes_cli.config import get_env_value, save_env_value + save_env_value("DEEPSEEK_API_KEY", "sk-existing") + save_env_value("OTHER_VAR", "keep-me") + + key, abort = _run_prompt(existing_key="sk-existing", choice="c") + assert key == "" + assert abort is True + # Cleared, but sibling entries untouched. + assert not get_env_value("DEEPSEEK_API_KEY") + assert get_env_value("OTHER_VAR") == "keep-me" + + +def test_ctrl_c_at_choice_prompt_keeps(profile_env): + from hermes_cli import main as m + + pconfig = _pconfig("deepseek") + with patch("builtins.input", side_effect=KeyboardInterrupt): + key, abort = m._prompt_api_key(pconfig, "sk-existing") + assert key == "sk-existing" + assert abort is False + + +# LM Studio no-auth placeholder ──────────────────────────────────────────────── + +def test_lmstudio_first_time_empty_uses_placeholder(profile_env): + from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER + from hermes_cli.config import get_env_value + + key, abort = _run_prompt( + existing_key="", choice="", new_key="", + provider_id="lmstudio", pconfig_name="lmstudio", + ) + assert key == LMSTUDIO_NOAUTH_PLACEHOLDER + assert abort is False + assert get_env_value("LM_API_KEY") == LMSTUDIO_NOAUTH_PLACEHOLDER + + +def test_lmstudio_replace_empty_does_not_overwrite_with_placeholder(profile_env): + """On REPLACE with empty input, preserve the user's existing key — do NOT + silently substitute the placeholder. The placeholder path only fires for + first-time configuration where the user has made no explicit choice yet.""" + from hermes_cli.config import get_env_value, save_env_value + save_env_value("LM_API_KEY", "my-real-lmstudio-key") + + key, abort = _run_prompt( + existing_key="my-real-lmstudio-key", choice="r", new_key="", + provider_id="lmstudio", pconfig_name="lmstudio", + ) + assert key == "my-real-lmstudio-key" + assert abort is False + assert get_env_value("LM_API_KEY") == "my-real-lmstudio-key"
-
3 PRs closed without merging
From the close comments: duplicate 1 · merged via another change 1 · superseded by another PR 1
-
#18355 fix(setup): add keep/replace/clear menu when API key already exists · merged via another change →
0ddc8ab,#20162,#17827 - #55406 fix: strip ASCII control characters from API keys before saving (#55335) · superseded by another PR
-
#58074 fix(config): strip control chars from saved credentials · duplicate →
#55336
Closed issues · 1
- Closed#16394[Feature]: `hermes setup` should allow replacing an existing API key instead of silently skipping the prompt
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #20162
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 5 May 2026