diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd9112..57ce635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ --- +## [v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3) + +- **`HERMES_WEBUI_SKIP_ONBOARDING=1`** (closes bug 1): Hosting providers can set this env var to bypass the first-run wizard entirely. Only takes effect when `chat_ready` is also true — a misconfigured deployment still shows the wizard. Accepts `1`, `true`, or `yes`. +- **API key takes effect immediately after onboarding** (closes bug 3): `apply_onboarding_setup` now sets `os.environ[env_var]` synchronously after writing the key to `.env`, so the running process can use it without a server restart. Also attempts to reload `hermes_cli`'s config cache as a belt-and-suspenders measure. + - 8 new tests in `tests/test_sprint39.py`; 776 tests total (up from 768) + ## [v0.50.5] Think-tag stripping with leading whitespace (PR #327) - **Fix think-tag rendering for models that emit leading whitespace** (e.g. MiniMax M2.7): Some models emit one or more newlines before the `` opening tag. The previous regex used a `^` anchor, so it only matched when `` was the very first character. When the anchor failed, the raw `` tag appeared in the rendered message body. diff --git a/api/onboarding.py b/api/onboarding.py index be9ddf7..caabbb8 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -383,8 +383,16 @@ def get_onboarding_status() -> dict: last_workspace = get_last_workspace() available_models = get_available_models() + # HERMES_WEBUI_SKIP_ONBOARDING=1 lets hosting providers (e.g. Agent37) ship + # a pre-configured instance without the wizard blocking the first load. + # Only takes effect when the system is actually chat_ready — a misconfigured + # deployment still shows the wizard so the user can fix it. + skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip() + skip_requested = skip_env in {"1", "true", "yes"} + auto_completed = skip_requested and bool(runtime.get("chat_ready")) + return { - "completed": bool(settings.get("onboarding_completed")), + "completed": bool(settings.get("onboarding_completed")) or auto_completed, "settings": { "default_model": settings.get("default_model") or DEFAULT_MODEL, "default_workspace": settings.get("default_workspace") @@ -461,14 +469,25 @@ def apply_onboarding_setup(body: dict) -> dict: if api_key: _write_env_file(env_path, {provider_meta["env_var"]: api_key}) + # Belt-and-braces: set directly on os.environ so the value is visible to + # any code in the same process that reads it before the next request cycle. + os.environ[provider_meta["env_var"]] = api_key + # Reload the hermes_cli provider/config cache so the next streaming call + # picks up the new key without requiring a server restart. try: from api.profiles import _reload_dotenv - _reload_dotenv(_get_active_hermes_home()) except Exception: pass + try: + # hermes_cli may cache config at import time; ask it to reload if possible. + from hermes_cli.config import reload as _cli_reload + _cli_reload() + except Exception: + pass + reload_config() return get_onboarding_status() diff --git a/bootstrap.py b/bootstrap.py index bf450f5..4ebaeb4 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -21,6 +21,8 @@ INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/mai REPO_ROOT = Path(__file__).resolve().parent DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1") DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787")) +# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when +# the environment is already fully configured (e.g. managed hosting). def info(msg: str) -> None: diff --git a/static/index.html b/static/index.html index 2075018..4890cd2 100644 --- a/static/index.html +++ b/static/index.html @@ -526,7 +526,7 @@
System
Instance version and access controls.
- v0.50.5 + v0.50.6
diff --git a/tests/test_sprint39.py b/tests/test_sprint39.py new file mode 100644 index 0000000..5a17e13 --- /dev/null +++ b/tests/test_sprint39.py @@ -0,0 +1,186 @@ +""" +Sprint 39 Tests: Skip-onboarding env var + onboarding key reload fix (PR A of issue #329). + +Covers: +- HERMES_WEBUI_SKIP_ONBOARDING=1 bypasses the wizard when chat_ready is true +- HERMES_WEBUI_SKIP_ONBOARDING=1 does NOT bypass when chat_ready is false +- HERMES_WEBUI_SKIP_ONBOARDING unset leaves default behaviour unchanged +- apply_onboarding_setup sets os.environ synchronously when an API key is saved +""" +import os +import unittest +from unittest.mock import patch + +import api.onboarding as mod + + +_READY_RUNTIME = { + "chat_ready": True, + "provider_configured": True, + "provider_ready": True, + "setup_state": "ready", + "provider_note": "Ready", + "current_provider": "openai", + "current_model": "gpt-4o", + "current_base_url": None, + "env_path": "/tmp/test.env", +} + +_NOT_READY_RUNTIME = { + "chat_ready": False, + "provider_configured": False, + "provider_ready": False, + "setup_state": "needs_provider", + "provider_note": "Needs setup", + "current_provider": None, + "current_model": None, + "current_base_url": None, + "env_path": "/tmp/test.env", +} + +_COMMON_PATCHES = [ + ("api.onboarding.load_settings", lambda: {}), + ("api.onboarding.get_config", lambda: {}), + ("api.onboarding.verify_hermes_imports",lambda: (True, [], [])), + ("api.onboarding.load_workspaces", lambda: []), + ("api.onboarding.get_last_workspace", lambda: "/tmp"), + ("api.onboarding.get_available_models", lambda: []), + ("api.onboarding.is_auth_enabled", lambda: False), + ("api.onboarding._build_setup_catalog", lambda cfg: {}), + ("api.onboarding._get_config_path", lambda: __import__("pathlib").Path("/tmp/fake.yaml")), +] + + +def _apply_patches(extra_patches=()): + patches = [] + for target, side_effect in _COMMON_PATCHES: + p = patch(target, side_effect=side_effect) + patches.append(p) + for target, side_effect in extra_patches: + p = patch(target, side_effect=side_effect) + patches.append(p) + return patches + + +class TestSkipOnboardingEnvVar(unittest.TestCase): + + def _run_status(self, runtime, env_override): + runtime_patches = [("api.onboarding._status_from_runtime", lambda cfg, ok: runtime)] + all_patches = _apply_patches(runtime_patches) + with patch.dict(os.environ, env_override, clear=False): + for p in all_patches: + p.start() + try: + return mod.get_onboarding_status() + finally: + for p in all_patches: + p.stop() + + def test_skip_env_1_and_chat_ready_marks_completed(self): + """HERMES_WEBUI_SKIP_ONBOARDING=1 + chat_ready=True → completed=True.""" + status = self._run_status(_READY_RUNTIME, {"HERMES_WEBUI_SKIP_ONBOARDING": "1"}) + self.assertTrue(status["completed"], + "completed must be True when skip env var is 1 and chat_ready") + + def test_skip_env_true_and_chat_ready_marks_completed(self): + """HERMES_WEBUI_SKIP_ONBOARDING=true also accepted.""" + status = self._run_status(_READY_RUNTIME, {"HERMES_WEBUI_SKIP_ONBOARDING": "true"}) + self.assertTrue(status["completed"]) + + def test_skip_env_yes_and_chat_ready_marks_completed(self): + """HERMES_WEBUI_SKIP_ONBOARDING=yes also accepted.""" + status = self._run_status(_READY_RUNTIME, {"HERMES_WEBUI_SKIP_ONBOARDING": "yes"}) + self.assertTrue(status["completed"]) + + def test_skip_env_set_but_not_chat_ready_does_not_skip(self): + """HERMES_WEBUI_SKIP_ONBOARDING=1 + chat_ready=False → still shows wizard.""" + status = self._run_status(_NOT_READY_RUNTIME, {"HERMES_WEBUI_SKIP_ONBOARDING": "1"}) + self.assertFalse(status["completed"], + "completed must be False when not chat_ready even if skip env set") + + def test_skip_env_unset_leaves_default_false(self): + """Without the env var, completed is False when settings are empty.""" + env = {k: v for k, v in os.environ.items() if k != "HERMES_WEBUI_SKIP_ONBOARDING"} + with patch.dict(os.environ, env, clear=True): + status = self._run_status(_READY_RUNTIME, {}) + self.assertFalse(status["completed"], + "completed must be False when env var absent and settings empty") + + def test_settings_completed_still_works_without_env_var(self): + """onboarding_completed in settings → completed=True regardless of env var.""" + runtime_patches = [("api.onboarding._status_from_runtime", lambda cfg, ok: _READY_RUNTIME)] + settings_patch = [("api.onboarding.load_settings", lambda: {"onboarding_completed": True})] + all_patches = _apply_patches(runtime_patches + settings_patch) + env = {k: v for k, v in os.environ.items() if k != "HERMES_WEBUI_SKIP_ONBOARDING"} + with patch.dict(os.environ, env, clear=True): + for p in all_patches: + p.start() + try: + status = mod.get_onboarding_status() + finally: + for p in all_patches: + p.stop() + self.assertTrue(status["completed"]) + + +class TestApplyOnboardingKeySync(unittest.TestCase): + """Verify that apply_onboarding_setup sets os.environ synchronously.""" + + def test_api_key_set_in_os_environ_after_apply(self): + """After apply_onboarding_setup with a key, os.environ must have the key.""" + import pathlib + + os.environ.pop("OPENAI_API_KEY", None) + + mock_cfg = {"model": {"provider": "openai", "default": "gpt-4o"}} + + with patch("api.onboarding._load_yaml_config", return_value=mock_cfg), \ + patch("api.onboarding._save_yaml_config"), \ + patch("api.onboarding._write_env_file"), \ + patch("api.onboarding.reload_config"), \ + patch("api.onboarding.get_onboarding_status", return_value={"completed": True}), \ + patch("api.onboarding._get_config_path", return_value=pathlib.Path("/tmp/fake.yaml")), \ + patch("api.onboarding._load_env_file", return_value={}), \ + patch("api.onboarding._provider_api_key_present", return_value=False), \ + patch("api.onboarding._get_active_hermes_home", return_value=pathlib.Path("/tmp")): + + mod.apply_onboarding_setup({ + "provider": "openai", + "model": "gpt-4o", + "api_key": "sk-test-key-123", + }) + + self.assertEqual(os.environ.get("OPENAI_API_KEY"), "sk-test-key-123", + "OPENAI_API_KEY must be set directly on os.environ after apply") + os.environ.pop("OPENAI_API_KEY", None) + + def test_no_key_provided_does_not_set_environ(self): + """If no api_key is given (key already present), os.environ is not clobbered.""" + import pathlib + + os.environ["OPENAI_API_KEY"] = "sk-existing-key" + + mock_cfg = {"model": {"provider": "openai", "default": "gpt-4o"}} + + with patch("api.onboarding._load_yaml_config", return_value=mock_cfg), \ + patch("api.onboarding._save_yaml_config"), \ + patch("api.onboarding._write_env_file"), \ + patch("api.onboarding.reload_config"), \ + patch("api.onboarding.get_onboarding_status", return_value={"completed": True}), \ + patch("api.onboarding._get_config_path", return_value=pathlib.Path("/tmp/fake.yaml")), \ + patch("api.onboarding._load_env_file", return_value={"OPENAI_API_KEY": "sk-existing-key"}), \ + patch("api.onboarding._provider_api_key_present", return_value=True), \ + patch("api.onboarding._get_active_hermes_home", return_value=pathlib.Path("/tmp")): + + mod.apply_onboarding_setup({ + "provider": "openai", + "model": "gpt-4o", + }) + + # Key must be unchanged + self.assertEqual(os.environ.get("OPENAI_API_KEY"), "sk-existing-key") + os.environ.pop("OPENAI_API_KEY", None) + + +if __name__ == "__main__": + unittest.main()