From e77ca8dbac2ceed22fecf558e2314966f5c014b7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 20:03:16 -0400 Subject: [PATCH] fix(#2698): keep the pinned home across isolated fallbacks --- api/profiles.py | 6 +- bootstrap.py | 2 + tests/test_bootstrap_foreground.py | 22 +++++++ tests/test_issue2698_isolated_hermes_home.py | 63 ++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index b04621080..cc8481b43 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -507,6 +507,8 @@ def install_cron_scheduler_profile_isolation() -> None: return original(job, *args, **kwargs) finally: event_profile = str((job or {}).get("profile") or "").strip() or None + if _is_isolated_profile_mode(): + event_profile = _isolated_profile_name() try: publish_session_list_changed("cron_complete", profile=event_profile) except TypeError: @@ -1102,7 +1104,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: ) # Resolve profile directory - if _is_root_profile(name): + if _is_isolated_profile_mode(): + home = Path(_INITIAL_HERMES_HOME).expanduser() + elif _is_root_profile(name): home = _DEFAULT_HERMES_HOME else: home = _resolve_named_profile_home(name) diff --git a/bootstrap.py b/bootstrap.py index e7e922738..f8f0ced24 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -549,6 +549,8 @@ def main() -> int: # for SO_EXCLUSIVEADDRUSE. _CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) subprocess.Popen([python_exe, server_path], + cwd=server_cwd, + env=os.environ.copy(), creationflags=_CREATE_NEW_PROCESS_GROUP) sys.exit(0) os.execv(python_exe, [python_exe, server_path]) diff --git a/tests/test_bootstrap_foreground.py b/tests/test_bootstrap_foreground.py index f0bd98a14..4aa200ae5 100644 --- a/tests/test_bootstrap_foreground.py +++ b/tests/test_bootstrap_foreground.py @@ -429,6 +429,28 @@ class TestForegroundEnvAndCwd: assert chdir_calls == [str(workspace)] + def test_windows_foreground_popen_uses_server_cwd_override(self, setup, monkeypatch, clean_env, tmp_path): + bs, _agent_dir = setup + workspace = tmp_path / "workspace-win" + workspace.mkdir() + monkeypatch.setenv("HERMES_WEBUI_SERVER_CWD", str(workspace)) + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"]) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(os, "chdir", lambda p: None) + + popen_calls = [] + + def fake_popen(*args, **kwargs): + popen_calls.append((args, kwargs)) + return None + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + with pytest.raises(SystemExit): + bs.main() + + assert popen_calls[0][1]["cwd"] == str(workspace) + def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env): bs, agent_dir = setup monkeypatch.setattr(sys, "argv", [ diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 94750b89e..46a9ae3e0 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -10,6 +10,7 @@ import os import io import sys import tempfile +import types from pathlib import Path from unittest import mock @@ -421,6 +422,28 @@ class TestProfileMutationsInIsolatedMode: except (ImportError, ValueError, RuntimeError): pass # expected in test env without hermes_cli + def test_switch_to_same_default_profile_keeps_pinned_home(self, temp_hermes_home, monkeypatch, tmp_path): + """A same-name switch for isolated profiles/default must keep using the pinned home.""" + isolated_default = temp_hermes_home / "profiles" / "default" + isolated_default.mkdir(parents=True) + (isolated_default / "workspace").mkdir() + base_workspace = tmp_path / "base-workspace" + isolated_workspace = tmp_path / "isolated-workspace" + base_workspace.mkdir() + isolated_workspace.mkdir() + (temp_hermes_home / "config.yaml").write_text(f"workspace: {base_workspace}\n", encoding="utf-8") + (isolated_default / "config.yaml").write_text(f"workspace: {isolated_workspace}\n", encoding="utf-8") + + monkeypatch.setenv("HERMES_HOME", str(isolated_default)) + monkeypatch.setenv("HERMES_BASE_HOME", "") + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default)) + monkeypatch.setattr(_profiles_mod, "list_profiles_api", lambda: []) + + result = switch_profile("default", process_wide=False) + + assert result["default_workspace"] == str(isolated_workspace.resolve()) + 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 @@ -467,6 +490,46 @@ class TestProfileMutationsInIsolatedMode: 403, ) + def test_scheduler_publishes_isolated_profile_after_foreign_job_profile(self, temp_single_profile, monkeypatch): + """Scheduled cron completion must publish the isolated profile identity.""" + events = [] + base_home = temp_single_profile.parent.parent + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_scheduler = types.ModuleType("cron.scheduler") + cron_scheduler.run_job = lambda job: events.append(("run", job["id"])) or "ok" + + class _Ctx: + def __init__(self, home): + self.home = str(home) + + def __enter__(self): + events.append(("enter", self.home)) + return self + + def __exit__(self, exc_type, exc, tb): + events.append(("exit", self.home)) + return False + + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + 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, "cron_profile_context_for_home", _Ctx) + monkeypatch.setattr( + _profiles_mod, + "publish_session_list_changed", + lambda reason, profile=None: events.append(("publish", reason, profile)), + ) + + _profiles_mod.install_cron_scheduler_profile_isolation() + + assert cron_scheduler.run_job({"id": "job2698", "profile": "other"}) == "ok" + assert events[-1] == ("publish", "cron_complete", "user1") + class TestNormalModePreservation: """Test that normal mode behavior is completely unchanged."""