Release v0.51.315 — Release KE (Phase 1: cross-platform workspace-fallback tests, #3780) (#3783)

Phase-1 test-portability fix (#3780/#3771, @rodboev): replace hard-coded POSIX paths + chmod simulation with monkeypatch-based simulation. Release gate caught + fixed a test-isolation regression (media-fixtures dir polluting workspace-suggest) — dot-prefixed it. Full suite 8188 passed. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-07 10:11:52 -07:00
1 parent 7b60151cc1
commit ee229f32ad
4 files changed
+166 -69

No files matched your search

+5
View File
@@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.315] — 2026-06-07 — Release KE (test infra — cross-platform workspace-fallback tests)
### Changed
- **Workspace-fallback tests are now cross-platform and deterministic.** Three tests simulated an unusable/unwritable workspace path by hard-coding a POSIX path (`/definitely/not/usable`) or by `chmod`-ing a temp dir read-only — which silently no-ops when the suite runs as root and is meaningless on Windows. They now simulate the failure by monkeypatching `_ensure_workspace_dir` / `Path.mkdir`, so the assertions hold identically on every platform and under any uid. Test-harness only; no shipped behavior change. (#3780 / #3771, @rodboev)
## [v0.51.314] — 2026-06-07 — Release KD (test infra — reliable test-server boot + diagnostics)
### Changed
+30 -20
View File
@@ -4,16 +4,30 @@ from pathlib import Path
import api.config as config
def _reject_workspace_candidate(monkeypatch, candidate: Path) -> None:
original_ensure = config._ensure_workspace_dir
resolved_candidate = candidate.resolve()
def wrapped(path: Path) -> bool:
if path.expanduser().resolve() == resolved_candidate:
return False
return original_ensure(path)
monkeypatch.setattr(config, "_ensure_workspace_dir", wrapped)
def test_resolve_default_workspace_falls_back_to_existing_home_work(monkeypatch, tmp_path):
preferred = tmp_path / "work"
preferred.mkdir()
state_dir = tmp_path / "state"
bad_candidate = tmp_path / "not-usable"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
_reject_workspace_candidate(monkeypatch, bad_candidate)
resolved = config.resolve_default_workspace("/definitely/not/usable")
resolved = config.resolve_default_workspace(str(bad_candidate))
assert resolved == preferred.resolve()
@@ -24,14 +38,16 @@ def test_save_settings_rewrites_bad_default_workspace_to_fallback(monkeypatch, t
preferred.mkdir()
state_dir = tmp_path / "state"
settings_file = tmp_path / "settings.json"
bad_candidate = tmp_path / "not-usable"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", preferred)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
_reject_workspace_candidate(monkeypatch, bad_candidate)
saved = config.save_settings({"default_workspace": "/definitely/not/usable"})
saved = config.save_settings({"default_workspace": str(bad_candidate)})
on_disk = json.loads(settings_file.read_text(encoding="utf-8"))
assert saved["default_workspace"] == str(preferred.resolve())
@@ -52,18 +68,15 @@ def test_resolve_default_workspace_creates_home_workspace_when_missing(monkeypat
def test_resolve_default_workspace_raises_when_all_candidates_fail(monkeypatch, tmp_path):
"""RuntimeError is raised when every candidate is unwritable."""
import stat, pytest
# Make tmp_path read-only so mkdir inside it fails
tmp_path.chmod(stat.S_IRUSR | stat.S_IXUSR)
import pytest
state_dir = tmp_path / "state"
monkeypatch.setattr(config, "HOME", tmp_path)
monkeypatch.setattr(config, "STATE_DIR", state_dir)
monkeypatch.delenv("HERMES_WEBUI_DEFAULT_WORKSPACE", raising=False)
try:
with pytest.raises(RuntimeError, match="Could not create or access"):
config.resolve_default_workspace(None)
finally:
tmp_path.chmod(stat.S_IRWXU) # restore for cleanup
monkeypatch.setattr(config, "_ensure_workspace_dir", lambda path: False)
with pytest.raises(RuntimeError, match="Could not create or access"):
config.resolve_default_workspace(None)
def test_workspace_candidates_deduplicates_home_workspace(monkeypatch, tmp_path):
@@ -94,16 +107,13 @@ def test_env_var_workspace_takes_priority_over_passed_raw(monkeypatch, tmp_path)
def test_ensure_workspace_dir_returns_false_for_unwritable_path(monkeypatch, tmp_path):
"""_ensure_workspace_dir returns False for a path that can't be created."""
import stat
# Make parent read-only so mkdir fails
parent = tmp_path / "ro_parent"
parent.mkdir()
parent.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
result = config._ensure_workspace_dir(parent / "child")
assert result is False
finally:
parent.chmod(stat.S_IRWXU)
def fail_mkdir(self, mode=0o777, parents=False, exist_ok=False):
raise PermissionError("simulated create failure")
monkeypatch.setattr(Path, "mkdir", fail_mkdir)
result = config._ensure_workspace_dir(tmp_path / "child")
assert result is False
def test_env_var_wins_over_settings_json_on_startup(monkeypatch, tmp_path):
+98 -24
View File
@@ -23,6 +23,7 @@ import urllib.parse
import urllib.request
from tests._pytest_port import BASE, TEST_STATE_DIR
from tests.conftest import TEST_WORKSPACE
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
@@ -30,6 +31,18 @@ I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
def _media_fixture_dir() -> pathlib.Path:
# Dot-prefixed so the persistent fixture dir stays OUT of
# /api/workspaces/suggest's default (non-hidden) results — otherwise it
# pollutes the shared TEST_WORKSPACE and breaks sibling tests that assert an
# exact workspace-suggestion set (e.g. test_sprint5
# test_workspace_suggest_hidden_dirs_only_when_requested). Kept under
# TEST_WORKSPACE (a MEDIA_ALLOWED_ROOT) and Windows-portable (no /tmp).
fixture_dir = TEST_WORKSPACE / ".media-fixtures"
fixture_dir.mkdir(parents=True, exist_ok=True)
return fixture_dir
# ── Static analysis: renderMd MEDIA stash ────────────────────────────────────
class TestMediaRenderMdStash(unittest.TestCase):
@@ -519,6 +532,64 @@ class TestMediaEndpointUnit(unittest.TestCase):
h4.status, 403,
"profile webui_state/sessions/*.json must be denied")
def test_media_allowed_roots_env_var_serves_outside_hermes_root(self):
"""MEDIA_ALLOWED_ROOTS must still allow legitimate outside-root media."""
from api import routes
class _Handler:
def __init__(self):
self.status = None
def send_response(self, code):
self.status = code
def send_header(self, *a, **k):
pass
def end_headers(self):
pass
class _W:
def write(self_inner, b):
pass
def flush(self_inner):
pass
wfile = _W()
headers = {}
png_bytes = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as extra:
hermes_home = pathlib.Path(home) / ".hermes"
hermes_home.mkdir(parents=True)
outside_root = pathlib.Path(extra).resolve()
image = outside_root / "settings_artifact.png"
image.write_bytes(png_bytes)
with mock.patch.dict(
os.environ,
{
"HERMES_HOME": str(hermes_home),
"MEDIA_ALLOWED_ROOTS": str(outside_root),
},
), mock.patch.object(
routes, "get_last_workspace", lambda: str(hermes_home / "workspace")
), mock.patch(
"api.auth.is_auth_enabled", lambda: False
):
handler = _Handler()
routes._handle_media(
handler,
SimpleNamespace(
query=f"path={urllib.parse.quote(str(image))}&inline=1",
path="/api/media",
),
)
self.assertEqual(
handler.status, 200,
"MEDIA_ALLOWED_ROOTS media outside Hermes roots must still serve",
)
def test_media_endpoints_advertise_byte_range_support(self):
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
self.assertIn("Accept-Ranges", routes_src)
@@ -596,7 +667,10 @@ class TestMediaEndpointIntegration(unittest.TestCase):
self.assertEqual(status, 400)
def test_nonexistent_file_returns_404(self):
_, status, _ = self._get("/api/media?path=/tmp/__hermes_nonexistent_12345.png")
missing = _media_fixture_dir() / "__hermes_nonexistent_12345.png"
_, status, _ = self._get(
"/api/media?path=" + urllib.parse.quote(str(missing))
)
self.assertEqual(status, 404)
def test_path_outside_allowed_root_rejected(self):
@@ -605,7 +679,7 @@ class TestMediaEndpointIntegration(unittest.TestCase):
self.assertIn(status, {403, 404})
def test_valid_png_served_with_image_mime(self):
"""Create a 1-pixel PNG in /tmp and verify it's served correctly."""
"""Create a 1-pixel PNG in the isolated test workspace and verify it serves."""
# Minimal valid 1x1 transparent PNG (67 bytes)
png_bytes = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
@@ -613,13 +687,13 @@ class TestMediaEndpointIntegration(unittest.TestCase):
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
with tempfile.NamedTemporaryFile(
suffix=".png", prefix="hermes_test_", dir="/tmp", delete=False
suffix=".png", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False
) as f:
f.write(png_bytes)
tmp_path = f.name
try:
body, status, headers = self._get(
f"/api/media?path={urllib.request.quote(tmp_path)}"
f"/api/media?path={urllib.parse.quote(tmp_path)}"
)
self.assertEqual(status, 200, f"Expected 200, got {status}")
ct = headers.get("Content-Type", "")
@@ -632,12 +706,12 @@ class TestMediaEndpointIntegration(unittest.TestCase):
"""MEDIA: audio paths stream inline and support byte ranges for playback."""
audio_bytes = b"RIFF" + (b"\x00" * 256)
with tempfile.NamedTemporaryFile(
suffix=".wav", prefix="hermes_test_", dir="/tmp", delete=False
suffix=".wav", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False
) as f:
f.write(audio_bytes)
tmp_path = f.name
try:
encoded = urllib.request.quote(tmp_path)
encoded = urllib.parse.quote(tmp_path)
body, status, headers = self._get(f"/api/media?path={encoded}&inline=1")
self.assertEqual(status, 200)
self.assertIn("audio/wav", headers.get("Content-Type", ""))
@@ -659,12 +733,12 @@ class TestMediaEndpointIntegration(unittest.TestCase):
"""HTML opens inline only when requested and always carries CSP sandbox."""
html_bytes = b"<!doctype html><title>Hermes</title><script>window.ok=1</script>"
with tempfile.NamedTemporaryFile(
suffix=".html", prefix="hermes_test_", dir="/tmp", delete=False
suffix=".html", prefix="hermes_test_", dir=_media_fixture_dir(), delete=False
) as f:
f.write(html_bytes)
tmp_path = f.name
try:
encoded = urllib.request.quote(tmp_path)
encoded = urllib.parse.quote(tmp_path)
body, status, headers = self._get(f"/api/media?path={encoded}")
self.assertEqual(status, 200)
@@ -690,7 +764,7 @@ class TestMediaEndpointIntegration(unittest.TestCase):
def test_path_traversal_rejected(self):
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote("/tmp/../../etc/passwd")
"/api/media?path=" + urllib.parse.quote("/tmp/../../etc/passwd")
)
self.assertIn(status, {403, 404})
@@ -709,7 +783,7 @@ class TestMediaEndpointIntegration(unittest.TestCase):
settings.write_text('{"secret":"value"}', encoding="utf-8")
try:
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote(str(settings.resolve()))
"/api/media?path=" + urllib.parse.quote(str(settings.resolve()))
)
self.assertEqual(
status, 403,
@@ -725,7 +799,7 @@ class TestMediaEndpointIntegration(unittest.TestCase):
sess_file.write_text('{"messages":[]}', encoding="utf-8")
try:
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote(str(sess_file.resolve()))
"/api/media?path=" + urllib.parse.quote(str(sess_file.resolve()))
)
self.assertEqual(
status, 403,
@@ -734,34 +808,34 @@ class TestMediaEndpointIntegration(unittest.TestCase):
finally:
sess_file.unlink(missing_ok=True)
def test_deny_list_does_not_overblock_legitimate_media(self):
"""#3234 follow-up: the state/secret deny-list must NOT block ordinary
media that merely shares a sensitive basename but lives OUTSIDE any
Hermes state root (e.g. a user artifact in /tmp named settings.json).
The deny is scoped to files under a Hermes root; a /tmp PNG named
settings.png — or even settings.json — is the user's own content and
must still be served (200), not 403.
def test_deny_list_does_not_overblock_active_workspace_media(self):
"""#3234 follow-up: workspace media with a sensitive-looking basename must
still serve when it lives under the active workspace carve-out.
"""
png_bytes = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
# A /tmp artifact whose stem collides with a denied basename — must serve
# because /tmp is not a Hermes state root.
# A test-workspace artifact whose stem collides with a denied basename
# must still serve because the active workspace carve-out allows
# legitimate user media even when the test workspace lives under
# TEST_STATE_DIR.
with tempfile.NamedTemporaryFile(
suffix=".png", prefix="settings_artifact_", dir="/tmp", delete=False
suffix=".png",
prefix="settings_artifact_",
dir=_media_fixture_dir(),
delete=False,
) as f:
f.write(png_bytes)
tmp_path = f.name
try:
body, status, headers = self._get(
f"/api/media?path={urllib.request.quote(tmp_path)}"
f"/api/media?path={urllib.parse.quote(tmp_path)}"
)
self.assertEqual(
status, 200,
f"a /tmp PNG outside any Hermes root must serve, got {status}",
f"a test-workspace PNG under the active workspace must serve, got {status}",
)
self.assertIn("image/png", headers.get("Content-Type", ""))
finally:
+33 -25
View File
@@ -33,14 +33,16 @@ _needs_yaml = pytest.mark.skipif(not _HAS_YAML, reason="PyYAML not installed —
# ---------------------------------------------------------------------------
def _make_status(*, config_exists: bool, chat_ready: bool, onboarding_done: bool = False):
def _make_status(
*, tmp_path: pathlib.Path, config_exists: bool, chat_ready: bool, onboarding_done: bool = False
):
"""Call get_onboarding_status() with a controlled filesystem + settings."""
import importlib
# Import fresh copies each call so module-level state doesn't bleed across
import api.onboarding as mod
fake_config_path = pathlib.Path("/tmp/_test_config.yaml")
fake_config_path = tmp_path / "_test_config.yaml"
settings = {"onboarding_completed": onboarding_done}
@@ -55,7 +57,7 @@ def _make_status(*, config_exists: bool, chat_ready: bool, onboarding_done: bool
"current_provider": "openrouter" if chat_ready else None,
"current_model": "anthropic/claude-sonnet-4.6" if chat_ready else None,
"current_base_url": None,
"env_path": "/tmp/.hermes_test/.env",
"env_path": str(tmp_path / ".hermes_test" / ".env"),
}
with (
@@ -81,42 +83,47 @@ def _make_status(*, config_exists: bool, chat_ready: bool, onboarding_done: bool
class TestOnboardingGate:
def test_config_exists_and_chat_ready_returns_completed_true(self):
def test_config_exists_and_chat_ready_returns_completed_true(self, tmp_path):
"""Primary fix: existing valid config → wizard must NOT fire."""
result = _make_status(config_exists=True, chat_ready=True)
result = _make_status(tmp_path=tmp_path, config_exists=True, chat_ready=True)
assert result["completed"] is True, (
"Wizard fired for existing Hermes user! "
"config.yaml + chat_ready must auto-complete onboarding."
)
def test_no_config_returns_completed_false(self):
def test_no_config_returns_completed_false(self, tmp_path):
"""Fresh install with no config → wizard should fire."""
result = _make_status(config_exists=False, chat_ready=False)
result = _make_status(tmp_path=tmp_path, config_exists=False, chat_ready=False)
assert result["completed"] is False, (
"Fresh install must show the wizard (completed should be False)."
)
def test_config_exists_but_not_chat_ready_still_shows_wizard(self):
def test_config_exists_but_not_chat_ready_still_shows_wizard(self, tmp_path):
"""Broken/incomplete config (config.yaml exists but chat_ready=False) →
still show wizard so the user can fix it."""
result = _make_status(config_exists=True, chat_ready=False)
result = _make_status(tmp_path=tmp_path, config_exists=True, chat_ready=False)
# Should NOT be auto-completed — config is present but broken
assert result["completed"] is False, (
"Broken config (chat_ready=False) must still show the wizard."
)
def test_onboarding_done_flag_always_respected(self):
def test_onboarding_done_flag_always_respected(self, tmp_path):
"""If user already completed onboarding in settings, never show wizard."""
result = _make_status(config_exists=False, chat_ready=False, onboarding_done=True)
result = _make_status(
tmp_path=tmp_path,
config_exists=False,
chat_ready=False,
onboarding_done=True,
)
assert result["completed"] is True
def test_config_exists_always_exposed_in_system(self):
def test_config_exists_always_exposed_in_system(self, tmp_path):
"""config_exists must still appear in the response system block."""
result = _make_status(config_exists=True, chat_ready=True)
result = _make_status(tmp_path=tmp_path, config_exists=True, chat_ready=True)
assert "config_exists" in result["system"]
assert result["system"]["config_exists"] is True
def test_persist_failure_does_not_break_status_endpoint(self):
def test_persist_failure_does_not_break_status_endpoint(self, tmp_path):
"""save_settings() failure (read-only FS, disk full) must not turn the
status endpoint into a 500. The persistence-across-restart guarantee
degrades but `completed` still reflects the live `config_auto_completed`
@@ -133,9 +140,9 @@ class TestOnboardingGate:
"current_provider": "openrouter",
"current_model": "anthropic/claude-sonnet-4.6",
"current_base_url": None,
"env_path": "/tmp/.hermes_test/.env",
"env_path": str(tmp_path / ".hermes_test" / ".env"),
}
fake_config_path = pathlib.Path("/tmp/_test_config.yaml")
fake_config_path = tmp_path / "_test_config.yaml"
with (
mock.patch.object(mod, "load_settings", return_value=settings),
@@ -164,10 +171,10 @@ class TestOnboardingGate:
class TestApplyOnboardingSetupGuard:
"""Fix #2: apply_onboarding_setup must not silently overwrite config.yaml."""
def _call_setup(self, body: dict, config_yaml_exists: bool):
def _call_setup(self, tmp_path: pathlib.Path, body: dict, config_yaml_exists: bool):
import api.onboarding as mod
fake_config_path = pathlib.Path("/tmp/_test_config.yaml")
fake_config_path = tmp_path / "_test_config.yaml"
with (
mock.patch.object(mod, "_get_config_path", return_value=fake_config_path),
@@ -175,9 +182,10 @@ class TestApplyOnboardingSetupGuard:
):
return mod.apply_onboarding_setup(body)
def test_setup_blocked_when_config_exists_without_confirm(self):
def test_setup_blocked_when_config_exists_without_confirm(self, tmp_path):
"""Must return an error dict (not raise) if config.yaml exists and no confirm_overwrite."""
result = self._call_setup(
tmp_path,
{
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.6",
@@ -192,12 +200,12 @@ class TestApplyOnboardingSetupGuard:
assert result.get("requires_confirm") is True
@_needs_yaml
def test_setup_allowed_with_confirm_overwrite(self):
def test_setup_allowed_with_confirm_overwrite(self, tmp_path):
"""With confirm_overwrite=True, setup may proceed (will hit real logic)."""
import api.onboarding as mod
import tempfile
fake_config_path = pathlib.Path("/tmp/_test_config_confirm.yaml")
fake_config_path = tmp_path / "_test_config_confirm.yaml"
fake_config_path.unlink(missing_ok=True) # start clean
try:
with tempfile.TemporaryDirectory() as tmp_home:
@@ -223,18 +231,18 @@ class TestApplyOnboardingSetupGuard:
fake_config_path.unlink(missing_ok=True)
@_needs_yaml
def test_setup_allowed_when_no_config_exists(self):
def test_setup_allowed_when_no_config_exists(self, tmp_path):
"""Fresh install: no config.yaml → setup proceeds normally (no blocking error)."""
import api.onboarding as mod
import tempfile
fake_config_path = pathlib.Path("/tmp/_test_config_fresh.yaml")
fake_config_path = tmp_path / "_test_config_fresh.yaml"
fake_config_path.unlink(missing_ok=True)
try:
with tempfile.TemporaryDirectory() as tmp_home:
tmp_home_path = pathlib.Path(tmp_home)
# Redirect both config path and hermes home so writes stay in /tmp,
# never touching the real ~/.hermes/.env.
# Redirect both config path and hermes home into temp dirs so the
# test never touches the real ~/.hermes/.env.
with (
mock.patch.object(mod, "_get_config_path", return_value=fake_config_path),
mock.patch.object(mod, "_get_active_hermes_home", return_value=tmp_home_path),