* fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh When users run 'python3 bootstrap.py' directly (the primary documented entry point in README), HERMES_WEBUI_HOST, HERMES_WEBUI_PORT and other .env settings were silently ignored because the shell-level 'source .env' in start.sh was never executed. Add _load_repo_dotenv() in bootstrap.py that reads REPO_ROOT/.env into os.environ before DEFAULT_HOST / DEFAULT_PORT are evaluated at module level. Uses unconditional assignment matching 'set -a; source .env' shell semantics. Only loads the repo .env (bootstrap config) — not ~/.hermes/.env, which the server still loads independently at startup for provider credentials. Reported in #730 by @leap233 who had HERMES_WEBUI_HOST=0.0.0.0 and HERMES_WEBUI_PORT=18787 in the webui .env; running bootstrap.py directly caused the server to ignore both settings. Tests: 15 new tests in tests/test_bootstrap_dotenv.py covering the full loader (key=value, comments, blank lines, quoted values, no-file, unreadable-file, overwrite semantics, values with = signs) and structural assertions that _load_repo_dotenv() is called before DEFAULT_HOST/PORT. 1613 tests total. * fix: address review feedback on PR #791 - bootstrap.py: document overwrite semantics and 'export' note in docstring - bootstrap.py: handle 'export FOO=bar' prefix (strip before splitting on =) - bootstrap.py: print warning to stderr on .env parse failure (not silent swallow) - bootstrap.py: add side-effect comment at _load_repo_dotenv() call site - CHANGELOG.md: restore v0.50.124 and v0.50.123 headers (were merged into v0.50.125 section, making three consecutive ### Fixed blocks with no ## header between them) - tests: fix test_noop_when_dotenv_unreadable to assert warning is emitted - tests: tighten test_does_not_set_empty_values with concrete assertion - tests: add test_export_prefix_stripped - tests: remove dead _import_bootstrap_with_env() helper (never called) 1614 tests total --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
This commit is contained in:
@@ -1,10 +1,16 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# 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
|
## [v0.50.124] — 2026-04-21
|
||||||
|
|
||||||
### Fixed
|
### 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
|
### 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)
|
- **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)
|
||||||
|
|||||||
44
bootstrap.py
44
bootstrap.py
@@ -19,6 +19,50 @@ from pathlib import Path
|
|||||||
|
|
||||||
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
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_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
|
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
|
||||||
|
|||||||
187
tests/test_bootstrap_dotenv.py
Normal file
187
tests/test_bootstrap_dotenv.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user