From 80f8ffc74c7b8994c9671408df15f10882726882 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 15 Jun 2026 15:33:15 +1000 Subject: [PATCH] fix(dashboard): pin machine-dashboard reroute to the machine root, not $HOME/.hermes (#46487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified machine-dashboard reroute (cmd_dashboard) re-execs a named-profile dashboard launch as the machine dashboard and dropped HERMES_HOME from the child env with the comment "so the child binds the machine root". That holds for a standard install (root == ~/.hermes) but breaks the Docker layout: the published image sets `ENV HERMES_HOME=/opt/data`, so once HERMES_HOME is unset the child falls back to $HOME/.hermes = /opt/data/.hermes — an empty, auto-seeded home. Two user-visible symptoms, one root cause (reported via support): 1. Dashboard Profiles page shows only an empty `default` — the real default/oracle/saga profiles live under /opt/data/profiles, but the rerouted child resolves _get_profiles_root() to /opt/data/.hermes/profiles. 2. The "Update Hermes" button runs `hermes update` inside the container repeatedly instead of bailing with the docker-update guidance. The Docker guard keys off detect_install_method(), which reads $HERMES_HOME/.install_method; the image stamps that at /opt/data, but the misresolved home has no stamp, no HERMES_MANAGED, and no .git → falls through to "pip", so the guard never fires. The reporter's workaround was to bind-mount the host dir at both /opt/data and /opt/data/.hermes so the two paths converge (at the cost of a self-referential recursion). Fix: resolve the machine root explicitly with get_default_hermes_root() and set it on the child env instead of popping HERMES_HOME. That helper returns the root for both layouts — ~/.hermes for a standard install, and /opt/data for Docker (it strips a trailing profiles/). Falls back to the old pop behaviour only if root resolution raises, so the reroute is never blocked. Regression tests in test_dashboard_unified_launch.py: the existing standard- install test now asserts the child carries HERMES_HOME == get_default_hermes_root() (not absent), and a new test_reexec_pins_docker_machine_root covers the Docker layout (HERMES_HOME=/opt/data/profiles/oracle → child gets /opt/data). Both fail against the pre-fix pop behaviour (mutation-verified). --- hermes_cli/main.py | 20 ++++++++- .../test_dashboard_unified_launch.py | 43 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 714a61cae6..acd82fd081 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10753,8 +10753,24 @@ def cmd_dashboard(args): if getattr(args, "skip_build", False): reexec_argv.append("--skip-build") env = os.environ.copy() - # Drop the profile HERMES_HOME so the child binds the machine root. - env.pop("HERMES_HOME", None) + # Pin the child to the machine ROOT, not the launching profile's + # HERMES_HOME. We must resolve the root explicitly instead of just + # dropping HERMES_HOME: in the Docker layout the machine root is + # /opt/data (set via `ENV HERMES_HOME=/opt/data`), so an unset + # HERMES_HOME falls back to $HOME/.hermes = /opt/data/.hermes — an + # empty, auto-seeded home where the dashboard sees only the default + # profile and the install-method stamp is missing (so the Docker + # update-button guard also misfires). get_default_hermes_root() + # returns the root for both layouts: ~/.hermes for a standard install + # and /opt/data for Docker (it strips a trailing profiles/). + # See the support report for the double-mount workaround this avoids. + try: + from hermes_constants import get_default_hermes_root + env["HERMES_HOME"] = str(get_default_hermes_root()) + except Exception: + # Best-effort: if root resolution fails, fall back to the prior + # behaviour (drop HERMES_HOME) rather than block the reroute. + env.pop("HERMES_HOME", None) # On Windows, os.execvpe() does not truly replace the process — it # spawns via CreateProcess then the parent exits. Under Python 3.14+ # this can crash with STATUS_ACCESS_VIOLATION (0xC0000005) when diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py index a02ad65971..2c46d29c99 100644 --- a/tests/hermes_cli/test_dashboard_unified_launch.py +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -57,6 +57,7 @@ class TestUnifiedDashboardRouting: assert opened == ["http://127.0.0.1:9119/?profile=worker_x"] def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch): + monkeypatch.delenv("HERMES_HOME", raising=False) monkeypatch.setattr( "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" ) @@ -79,8 +80,46 @@ class TestUnifiedDashboardRouting: assert "-p" in argv and argv[argv.index("-p") + 1] == "default" assert "--open-profile" in argv assert argv[argv.index("--open-profile") + 1] == "worker_x" - # Profile HERMES_HOME dropped so the child binds the machine root. - assert "HERMES_HOME" not in env + # The child is pinned to the machine ROOT, not the launching profile's + # HERMES_HOME. For a standard install (HERMES_HOME unset) that root is + # the platform-native default (~/.hermes), NOT dropped — see the Docker + # test below for why we resolve explicitly instead of popping. + from hermes_constants import get_default_hermes_root + assert env.get("HERMES_HOME") == str(get_default_hermes_root()) + + def test_reexec_pins_docker_machine_root(self, main_mod, monkeypatch): + """In the Docker layout (HERMES_HOME=/opt/data, profiles under + /opt/data/profiles/) the reroute must pin the child to the + machine root /opt/data — NOT drop HERMES_HOME. + + Dropping it makes the child fall back to $HOME/.hermes + (= /opt/data/.hermes), an empty auto-seeded home, so the dashboard + shows only the default profile and the .install_method stamp is + missing (which also misfires the Docker update-button guard). + Regression test for the support report. + """ + monkeypatch.setenv("HERMES_HOME", "/opt/data/profiles/oracle") + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "oracle" + ) + monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False) + execs = [] + + def fake_exec(exe, argv, env): + execs.append((exe, argv, env)) + raise SystemExit(0) + + monkeypatch.setattr(main_mod.os, "execvpe", fake_exec) + + with pytest.raises(SystemExit): + main_mod.cmd_dashboard(_args()) + + assert len(execs) == 1 + _exe, _argv, env = execs[0] + # get_default_hermes_root() strips the trailing profiles/, so the + # child binds /opt/data — where the real default/oracle/saga profiles + # and the .install_method stamp actually live. + assert env.get("HERMES_HOME") == "/opt/data" def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch): """A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT