diff --git a/CHANGELOG.md b/CHANGELOG.md index a2aeb6d..9165b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # Hermes Web UI -- Changelog +## [v0.50.125] — 2026-04-21 + +### Fixed +- **`python3 bootstrap.py` now honours `.env` settings** — running bootstrap.py directly (the primary documented entry point) previously ignored `HERMES_WEBUI_HOST`, `HERMES_WEBUI_PORT`, and other repo `.env` settings because `start.sh`'s `source .env` step was skipped. bootstrap.py now loads `REPO_ROOT/.env` itself before reading any env-var defaults, making the two launch paths identical. Reported in #730 by @leap233. (#791) + ## [v0.50.124] — 2026-04-21 ### Fixed -- **Settings version badge now shows the real running version** — the badge in the Settings → System panel was hardcoded to `v0.50.87` (36 releases behind) and the HTTP `Server:` header said `HermesWebUI/0.50.38` (85 behind). Both are now resolved dynamically at server startup from `git describe --tags --always --dirty`. Docker images (where `.git` is excluded) receive the correct tag via a build-time `ARG HERMES_VERSION` written to `api/_version.py`. No manual "update the badge" step is needed going forward — tagging is sufficient. Version file parsing uses regex instead of `exec()` for supply-chain safety. (#790) +- **Settings version badge now shows the real running version** — the badge in the Settings → System panel was hardcoded to `v0.50.87` (36 releases behind) and the HTTP `Server:` header said `HermesWebUI/0.50.38` (85 behind). Both are now resolved dynamically at server startup from `git describe --tags --always --dirty`. Docker images (where `.git` is excluded) receive the correct tag via a build-time `ARG HERMES_VERSION` written to `api/_version.py`. No manual "update the badge" step is needed going forward — tagging is sufficient. Version file parsing uses regex instead of `exec()` for supply-chain safety. (#790, #792) +## [v0.50.123] — 2026-04-21 ### Fixed - **Default model change surfaced stale value after model-list TTL cache landed** — `set_hermes_default_model()` now explicitly invalidates `_available_models_cache` after `reload_config()`. The 60s TTL cache introduced in v0.50.121 (#780) only invalidates on config-file mtime change, but `reload_config()` resyncs `_cfg_mtime` before `get_available_models()` runs — so the mtime check never fires and the POST response (plus downstream reads within the TTL window) returned the previous model until the cache expired. Root cause of the `test_default_model_updates_hermes_config` CI flake as well. (#788) diff --git a/bootstrap.py b/bootstrap.py index ec86aab..80b3639 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -19,6 +19,50 @@ from pathlib import Path INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" REPO_ROOT = Path(__file__).resolve().parent + + +def _load_repo_dotenv() -> None: + """Load REPO_ROOT/.env into os.environ. + + Mirrors what start.sh does via ``set -a; source .env`` so that running + ``python3 bootstrap.py`` directly behaves identically to ``./start.sh``. + Variables are set unconditionally (matching shell source semantics), so a + value in .env overrides one already present in the shell environment. + To keep a CLI-supplied value, unset it from .env or launch via start.sh + and override there. + + Only loads the webui repo .env — not ~/.hermes/.env, which the server + loads independently at startup for provider credentials. + + Note: does not handle the ``export FOO=bar`` prefix — strip ``export`` + from .env values if copy-pasting from a shell rc file. + """ + env_path = REPO_ROOT / ".env" + if not env_path.exists(): + return + try: + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + # Strip optional 'export' prefix (common in copy-pasted shell snippets) + if k.startswith("export "): + k = k[7:].strip() + v = v.strip().strip('"').strip("'") + if k: + os.environ[k] = v + except Exception as exc: + import sys as _sys + print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr) + + +# Side effect: loads REPO_ROOT/.env into os.environ on import. +# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up +# values from .env even when bootstrap.py is invoked directly (not via start.sh). +_load_repo_dotenv() + 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 diff --git a/tests/test_bootstrap_dotenv.py b/tests/test_bootstrap_dotenv.py new file mode 100644 index 0000000..da8715b --- /dev/null +++ b/tests/test_bootstrap_dotenv.py @@ -0,0 +1,187 @@ +""" +Tests for bootstrap.py .env loading fix (issue #730). + +bootstrap.py is the primary documented entry point ("python3 bootstrap.py"). +Previously it did not load REPO_ROOT/.env, so HERMES_WEBUI_HOST, HERMES_WEBUI_PORT +etc. were silently ignored when launching without start.sh. + +Covers: + 1. _load_repo_dotenv() sets env vars from a repo .env file + 2. _load_repo_dotenv() ignores commented lines and blank lines + 3. _load_repo_dotenv() strips quotes from values + 4. _load_repo_dotenv() is a no-op when .env does not exist + 5. _load_repo_dotenv() prints a warning (not crash) on unreadable .env + 6. _load_repo_dotenv() overwrites existing env vars (shell source semantics) + 7. _load_repo_dotenv() handles 'export FOO=bar' prefix + 8. _load_repo_dotenv() preserves values containing '=' + 9. Variables are set unconditionally (not setdefault) + 10. Structural: loader is called before DEFAULT_HOST/DEFAULT_PORT +""" +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +REPO_ROOT = Path(__file__).parent.parent + + +class TestLoadRepoDotenv: + + def setup_method(self): + self._saved_env = os.environ.copy() + + def teardown_method(self): + os.environ.clear() + os.environ.update(self._saved_env) + + def _run(self, tmp_path, env_content: str): + """Write .env to tmp_path and run _load_repo_dotenv() with that root.""" + import bootstrap as bs + (tmp_path / ".env").write_text(env_content, encoding="utf-8") + orig_root = bs.REPO_ROOT + try: + bs.REPO_ROOT = tmp_path + bs._load_repo_dotenv() + finally: + bs.REPO_ROOT = orig_root + + def test_sets_env_var_from_dotenv(self, tmp_path): + """Basic key=value is loaded into os.environ.""" + self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n") + assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0" + + def test_sets_port_from_dotenv(self, tmp_path): + """HERMES_WEBUI_PORT is loaded as a string (caller does int() conversion).""" + self._run(tmp_path, "HERMES_WEBUI_PORT=18787\n") + assert os.environ.get("HERMES_WEBUI_PORT") == "18787" + + def test_ignores_comment_lines(self, tmp_path): + """Lines starting with # are not loaded.""" + os.environ.pop("HERMES_WEBUI_HOST", None) + self._run(tmp_path, "# HERMES_WEBUI_HOST=should-be-ignored\n") + assert os.environ.get("HERMES_WEBUI_HOST") is None + + def test_ignores_blank_lines(self, tmp_path): + """Blank lines are silently skipped without error.""" + self._run(tmp_path, "\n\nHERMES_WEBUI_PORT=9000\n\n") + assert os.environ.get("HERMES_WEBUI_PORT") == "9000" + + def test_strips_double_quoted_values(self, tmp_path): + """Values wrapped in double quotes are stripped.""" + self._run(tmp_path, 'HERMES_WEBUI_HOST="0.0.0.0"\n') + assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0" + + def test_strips_single_quoted_values(self, tmp_path): + """Values wrapped in single quotes are stripped.""" + self._run(tmp_path, "HERMES_WEBUI_HOST='0.0.0.0'\n") + assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0" + + def test_noop_when_no_dotenv(self, tmp_path): + """No .env file — function returns silently without error.""" + import bootstrap as bs + orig = bs.REPO_ROOT + try: + bs.REPO_ROOT = tmp_path # tmp_path has no .env + bs._load_repo_dotenv() # must not raise + finally: + bs.REPO_ROOT = orig + + def test_noop_when_dotenv_unreadable(self, tmp_path, capsys): + """Unreadable .env prints a warning to stderr — does not crash.""" + import bootstrap as bs + env_path = tmp_path / ".env" + env_path.write_text("HERMES_WEBUI_PORT=9999\n") + orig = bs.REPO_ROOT + try: + bs.REPO_ROOT = tmp_path + with patch("pathlib.Path.read_text", side_effect=PermissionError("no access")): + bs._load_repo_dotenv() # must not raise + finally: + bs.REPO_ROOT = orig + captured = capsys.readouterr() + assert "bootstrap" in captured.err.lower() or "warning" in captured.err.lower() or \ + "could not load" in captured.err.lower(), ( + "_load_repo_dotenv() should print a warning to stderr on read failure" + ) + + def test_overwrites_existing_env_var(self, tmp_path): + """Unconditional overwrite matches shell source semantics.""" + os.environ["HERMES_WEBUI_HOST"] = "127.0.0.1" + self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n") + assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0" + + def test_does_not_set_empty_values(self, tmp_path): + """A key whose value is empty after stripping is not set to a non-empty string.""" + os.environ.pop("HERMES_EMPTY_KEY", None) + self._run(tmp_path, 'HERMES_EMPTY_KEY=""\n') + # The current implementation sets key to "" (empty string) — verify it is + # not set to a non-empty string, which would be clearly wrong. + val = os.environ.get("HERMES_EMPTY_KEY") + assert val != "something-wrong", "Empty-value key must not be set to a non-empty string" + # Specifically: empty string or absent are both acceptable behaviours. + assert val in (None, ""), f"Unexpected value for empty-quoted key: {val!r}" + + def test_multiple_keys_all_loaded(self, tmp_path): + """Multiple key=value pairs in one file are all loaded.""" + content = "HERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=18787\n" + self._run(tmp_path, content) + assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0" + assert os.environ.get("HERMES_WEBUI_PORT") == "18787" + + def test_value_with_equals_sign_preserved(self, tmp_path): + """Values containing '=' (e.g. base64) are preserved correctly.""" + self._run(tmp_path, "MY_KEY=abc=def==\n") + assert os.environ.get("MY_KEY") == "abc=def==" + + def test_export_prefix_stripped(self, tmp_path): + """'export FOO=bar' lines are parsed correctly — export prefix is stripped.""" + self._run(tmp_path, "export HERMES_WEBUI_HOST=0.0.0.0\n") + assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0", ( + "'export KEY=value' lines must set KEY, not 'export KEY'" + ) + + +# --------------------------------------------------------------------------- +# Structural tests — confirm the fix is in place +# --------------------------------------------------------------------------- + +class TestBootstrapStructure: + + def test_load_repo_dotenv_function_exists(self): + """bootstrap.py must export _load_repo_dotenv().""" + import bootstrap as bs + assert callable(getattr(bs, "_load_repo_dotenv", None)), ( + "bootstrap.py must define _load_repo_dotenv() so that " + "python3 bootstrap.py loads REPO_ROOT/.env before reading env defaults" + ) + + def test_dotenv_loaded_before_default_host_port(self): + """_load_repo_dotenv() call must appear before DEFAULT_HOST/DEFAULT_PORT in source.""" + src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8") + load_pos = src.find("_load_repo_dotenv()") + host_pos = src.find("DEFAULT_HOST") + port_pos = src.find("DEFAULT_PORT") + assert load_pos != -1, "_load_repo_dotenv() call not found in bootstrap.py" + assert load_pos < host_pos, ( + "_load_repo_dotenv() must be called before DEFAULT_HOST assignment " + "so that HERMES_WEBUI_HOST from .env is picked up" + ) + assert load_pos < port_pos, ( + "_load_repo_dotenv() must be called before DEFAULT_PORT assignment " + "so that HERMES_WEBUI_PORT from .env is picked up" + ) + + def test_start_sh_and_bootstrap_equivalent_env_loading(self): + """start.sh sources .env before bootstrap.py; bootstrap.py must now do the same.""" + start_sh = (REPO_ROOT / "start.sh").read_text(encoding="utf-8") + bootstrap_src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8") + # start.sh sources .env + assert "source" in start_sh and ".env" in start_sh, ( + "start.sh should still source .env (regression guard)" + ) + # bootstrap.py now loads it too + assert "_load_repo_dotenv" in bootstrap_src, ( + "bootstrap.py must load .env so direct invocation matches start.sh behaviour" + )