feat: HERMES_WEBUI_SKIP_ONBOARDING env var + synchronous key reload (#330)
Fixes bugs 1+3 from issue #329. Skip-onboarding env var (with chat_ready guard); os.environ set synchronously after key write. 8 new tests, 776 total.
This commit is contained in:
@@ -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)
|
## [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 `<think>` opening tag. The previous regex used a `^` anchor, so it only matched when `<think>` was the very first character. When the anchor failed, the raw `</think>` tag appeared in the rendered message body.
|
- **Fix think-tag rendering for models that emit leading whitespace** (e.g. MiniMax M2.7): Some models emit one or more newlines before the `<think>` opening tag. The previous regex used a `^` anchor, so it only matched when `<think>` was the very first character. When the anchor failed, the raw `</think>` tag appeared in the rendered message body.
|
||||||
|
|||||||
@@ -383,8 +383,16 @@ def get_onboarding_status() -> dict:
|
|||||||
last_workspace = get_last_workspace()
|
last_workspace = get_last_workspace()
|
||||||
available_models = get_available_models()
|
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 {
|
return {
|
||||||
"completed": bool(settings.get("onboarding_completed")),
|
"completed": bool(settings.get("onboarding_completed")) or auto_completed,
|
||||||
"settings": {
|
"settings": {
|
||||||
"default_model": settings.get("default_model") or DEFAULT_MODEL,
|
"default_model": settings.get("default_model") or DEFAULT_MODEL,
|
||||||
"default_workspace": settings.get("default_workspace")
|
"default_workspace": settings.get("default_workspace")
|
||||||
@@ -461,14 +469,25 @@ def apply_onboarding_setup(body: dict) -> dict:
|
|||||||
|
|
||||||
if api_key:
|
if api_key:
|
||||||
_write_env_file(env_path, {provider_meta["env_var"]: 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:
|
try:
|
||||||
from api.profiles import _reload_dotenv
|
from api.profiles import _reload_dotenv
|
||||||
|
|
||||||
_reload_dotenv(_get_active_hermes_home())
|
_reload_dotenv(_get_active_hermes_home())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
reload_config()
|
||||||
return get_onboarding_status()
|
return get_onboarding_status()
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/mai
|
|||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
||||||
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
|
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:
|
def info(msg: str) -> None:
|
||||||
|
|||||||
@@ -526,7 +526,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.5</span>
|
<span class="settings-version-badge">v0.50.6</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
186
tests/test_sprint39.py
Normal file
186
tests/test_sprint39.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user