From 41f2015a327c077e150459789cf1270637d0658a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 19:38:09 -0400 Subject: [PATCH] fix(#2698): close isolated profile escape hatches --- api/profiles.py | 10 ++- api/routes.py | 2 + static/panels.js | 5 +- tests/test_issue2698_isolated_hermes_home.py | 76 ++++++++++++++++++++ tests/test_issue3623_profile_visibility.py | 11 +++ 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index e2e90e46..c18cfe9f 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -442,6 +442,14 @@ def _home_for_scheduled_cron_job(job: dict) -> Path: fall back to the server default rather than crashing every scheduler tick. """ raw = str((job or {}).get('profile') or '').strip() + if _is_isolated_profile_mode(): + active = _isolated_profile_name() + if raw and not _profiles_match(raw, active): + logger.warning( + "Cron job %s references profile %r outside isolated profile %r; falling back to isolated home", + (job or {}).get('id', '?'), raw, active, + ) + return get_active_hermes_home() if not raw: return get_active_hermes_home() if _is_root_profile(raw): @@ -1414,7 +1422,7 @@ def list_profiles_api() -> list: return [{ 'name': active, 'path': str(hermes_home), - 'is_default': _is_root_profile(active), + 'is_default': active == 'default', 'is_active': True, 'gateway_running': False, 'model': None, diff --git a/api/routes.py b/api/routes.py index ba3ab815..6fc086f2 100644 --- a/api/routes.py +++ b/api/routes.py @@ -18088,6 +18088,8 @@ def _handle_session_import_cli(handler, body): if requested_profile == "": return bad(handler, "invalid profile", 400) allow_all_profiles = _request_wants_all_profiles_import(body) + if allow_all_profiles and _is_isolated_profile_mode(): + return bad(handler, "all_profiles import is not allowed in isolated profile mode", 403) if allow_all_profiles and not requested_profile: return bad(handler, "profile is required for all_profiles import", 400) diff --git a/static/panels.js b/static/panels.js index f834ca01..5c2a884f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5706,8 +5706,9 @@ function _setProfileHeaderButtons(mode, p, activeName){ if (mode === 'read') { const isActive = p && p.name === activeName; const isDefault = !!(p && p.is_default); - if (isActive) hide(actBtn); else show(actBtn); - if (isDefault) hide(delBtn); else show(delBtn); + const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode); + if (isActive || singleProfileMode) hide(actBtn); else show(actBtn); + if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create') { hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn); diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 000168ef..8b8e3e47 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -7,6 +7,8 @@ of other profiles, and hide multi-profile UI affordances. """ import os +import io +import sys import tempfile from pathlib import Path from unittest import mock @@ -320,6 +322,27 @@ class TestIsolatedRuntimePinning: assert len(rows) == 1 assert Path(rows[0]["path"]) == isolated_default + def test_list_profiles_isolated_fallback_does_not_reenter_root_profile_lookup(self, temp_single_profile, monkeypatch): + """The isolated fallback must not recurse back through _is_root_profile.""" + base_home = temp_single_profile.parent.parent + monkeypatch.setenv("HERMES_HOME", str(temp_single_profile)) + monkeypatch.setenv("HERMES_BASE_HOME", "") + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", base_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(temp_single_profile)) + monkeypatch.setattr(_profiles_mod, "_get_profile_skills_stats", lambda _path: (0, 0)) + monkeypatch.setattr( + _profiles_mod, + "_is_root_profile", + lambda _name: (_ for _ in ()).throw(AssertionError("_is_root_profile should not run in isolated fallback")), + ) + + with mock.patch.dict(sys.modules, {"hermes_cli": None, "hermes_cli.profiles": None}): + rows = list_profiles_api() + + assert len(rows) == 1 + assert rows[0]["name"] == "user1" + assert rows[0]["is_default"] is False + class TestProfileMutationsInIsolatedMode: """Test that create/delete/switch are rejected (403) in isolated mode.""" @@ -385,6 +408,52 @@ class TestProfileMutationsInIsolatedMode: except (ImportError, ValueError, RuntimeError): pass # expected in test env without hermes_cli + def test_scheduled_cron_jobs_stay_pinned_to_isolated_home(self, temp_single_profile, monkeypatch): + """Scheduler jobs must not resolve foreign profile homes in isolated mode.""" + base_home = temp_single_profile.parent.parent + monkeypatch.setenv("HERMES_HOME", str(temp_single_profile)) + monkeypatch.setenv("HERMES_BASE_HOME", "") + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", base_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(temp_single_profile)) + + assert _profiles_mod._home_for_scheduled_cron_job({"id": "job2698", "profile": "other"}) == temp_single_profile + + def test_cli_import_all_profiles_is_rejected_in_isolated_mode(self, monkeypatch): + """Direct all_profiles CLI imports must not bypass isolated-profile boundaries.""" + import api.routes as routes + + captured = {} + + class _Handler: + def __init__(self): + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + pass + + def end_headers(self): + pass + + monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: True) + monkeypatch.setattr( + routes, + "bad", + lambda h, m, c=400: (captured.__setitem__("bad", (m, c)), True)[1], + ) + + routes._handle_session_import_cli( + _Handler(), + {"session_id": "foreign-cli-2698", "all_profiles": 1, "profile": "other"}, + ) + + assert captured["bad"] == ( + "all_profiles import is not allowed in isolated profile mode", + 403, + ) + class TestNormalModePreservation: """Test that normal mode behavior is completely unchanged.""" @@ -410,3 +479,10 @@ class TestNormalModePreservation: except ImportError: # hermes_cli not available, skip pass + + +def test_profiles_panel_hides_delete_controls_in_single_profile_mode(): + panels_js = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8") + + assert "const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode);" in panels_js + assert "if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn);" in panels_js diff --git a/tests/test_issue3623_profile_visibility.py b/tests/test_issue3623_profile_visibility.py index fbe2884f..7a86bbe9 100644 --- a/tests/test_issue3623_profile_visibility.py +++ b/tests/test_issue3623_profile_visibility.py @@ -5,6 +5,8 @@ import types from pathlib import Path from types import SimpleNamespace +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -21,6 +23,15 @@ def _profile_row(name: str, path: Path, *, is_default: bool = False): ) +@pytest.fixture(autouse=True) +def _clear_profile_rows_cache(): + import api.profiles as profiles + + profiles._LIST_PROFILES_CACHE = None + yield + profiles._LIST_PROFILES_CACHE = None + + def _install_fake_hermes_profiles(monkeypatch, rows): hermes_cli = types.ModuleType("hermes_cli") profiles_mod = types.ModuleType("hermes_cli.profiles")