Files
hermes-webui/tests/test_bootstrap_dotenv.py
alfred-rootson b71c88f292 fix(workspace): preserve remote POSIX paths across Session & streaming callsites (#7131) (#7168)
Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution.

Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com>
2026-09-15 15:37:11 -07:00

284 lines
12 KiB
Python

"""
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_preserve_existing_env_keeps_ctl_overrides(self, tmp_path):
"""ctl.sh can ask bootstrap.py to keep wrapper-provided env values."""
os.environ["HERMES_WEBUI_PRESERVE_ENV"] = "1"
os.environ["HERMES_HOME"] = "/runtime/hermesOne"
os.environ["HERMES_WEBUI_PASSWORD"] = ""
self._run(
tmp_path,
"HERMES_HOME=/repo/default\nHERMES_WEBUI_PASSWORD=repo-password\n",
)
assert os.environ.get("HERMES_HOME") == "/runtime/hermesOne"
assert os.environ.get("HERMES_WEBUI_PASSWORD") == ""
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"
)
class TestLeakedWebuiPasswordIsolation:
"""#7168 review: a local repo .env containing HERMES_WEBUI_PASSWORD leaks
into os.environ when any test imports bootstrap (import-time
_load_repo_dotenv() runs OUTSIDE monkeypatch's undo scope). The conftest
autouse guard strips the leaked var around every test so later tests
don't see is_auth_enabled()==True (the #5588 failure shape)."""
def _load_guard(self):
import importlib.util
cpath = Path(__file__).resolve().parent / "conftest.py"
spec = importlib.util.spec_from_file_location("_test_conftest", cpath)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod._strip_leaked_webui_password_env
def test_conftest_guard_strips_leaked_password(self):
strip = self._load_guard()
os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv"
try:
strip()
assert "HERMES_WEBUI_PASSWORD" not in os.environ
finally:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
def test_conftest_guard_preserves_intentional_empty_override(self):
"""ctl.sh-style override semantics: an explicitly empty value means
'keep auth off' and must survive the strip."""
strip = self._load_guard()
sentinel = object()
os.environ["HERMES_WEBUI_PASSWORD"] = ""
try:
strip()
assert os.environ.get("HERMES_WEBUI_PASSWORD", sentinel) == ""
finally:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
class TestLeakedHermesCommandIsolation:
"""#7168 re-gate round 7: a local repo .env carrying HERMES_COMMAND leaks
into os.environ via bootstrap import-time _load_repo_dotenv() and
redirects gateway_restart._resolve_hermes_command() away from its mocked
shutil.which result — every later active-profile-restart test then fails
on a machine-specific CLI path. The autouse conftest guard strips the
leaked var around every test; upstream code never reads HERMES_COMMAND,
so stripping restores exact upstream semantics."""
def _load_guard(self):
import importlib.util
cpath = Path(__file__).resolve().parent / "conftest.py"
spec = importlib.util.spec_from_file_location("_test_conftest_hc", cpath)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod._strip_leaked_webui_password_env
def test_conftest_guard_strips_leaked_hermes_command(self):
strip = self._load_guard()
os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper"
try:
strip()
assert "HERMES_COMMAND" not in os.environ
finally:
os.environ.pop("HERMES_COMMAND", None)
def test_conftest_guard_still_strips_password_when_command_leaks(self):
"""Both leaked vars are stripped in one pass (password branch must
not be short-circuited by the HERMES_COMMAND handling)."""
strip = self._load_guard()
os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv"
os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper"
try:
strip()
assert "HERMES_WEBUI_PASSWORD" not in os.environ
assert "HERMES_COMMAND" not in os.environ
finally:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
os.environ.pop("HERMES_COMMAND", None)