diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2e580bea84..babcad7191 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2499,8 +2499,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): print() override = "" if override and base_url_env: - save_env_value(base_url_env, override) - effective_base = override + if not override.startswith(("http://", "https://")): + print(" Invalid URL — must start with http:// or https://. Keeping current value.") + else: + save_env_value(base_url_env, override) + effective_base = override # Model selection — resolution order: # 1. models.dev registry (cached, filtered for agentic/tool-capable models) diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 55f7ac69c7..a06facd300 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -257,3 +257,76 @@ class TestProviderPersistsAfterModelSave: assert model.get("provider") == "opencode-go" assert model.get("default") == "minimax-m2.5" assert model.get("api_mode") == "anthropic_messages" + + +class TestBaseUrlValidation: + """Reject non-URL values in the base URL prompt (e.g. shell commands).""" + + def test_invalid_base_url_rejected(self, config_home, monkeypatch, capsys): + """Typing a non-URL string should not be saved as the base URL.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + # User types a shell command instead of a URL at the base URL prompt + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="nano ~/.hermes/.env"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + # The garbage value should NOT have been saved + saved = get_env_value("GLM_BASE_URL") or "" + assert not saved or saved.startswith(("http://", "https://")), \ + f"Non-URL value was saved as GLM_BASE_URL: {saved}" + captured = capsys.readouterr() + assert "Invalid URL" in captured.out + + def test_valid_base_url_accepted(self, config_home, monkeypatch): + """A proper URL should be saved normally.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="https://custom.z.ai/api/paas/v4"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + saved = get_env_value("GLM_BASE_URL") or "" + assert saved == "https://custom.z.ai/api/paas/v4" + + def test_empty_base_url_keeps_default(self, config_home, monkeypatch): + """Pressing Enter (empty) should not change the base URL.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + monkeypatch.delenv("GLM_BASE_URL", raising=False) + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value=""): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + saved = get_env_value("GLM_BASE_URL") or "" + assert saved == "", "Empty input should not save a base URL"