diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 231ff8f5be..fc581ec388 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -797,6 +797,7 @@ from hermes_cli.main_dashboard import ( _finalize_update_output, _find_stale_dashboard_pids, _install_hangup_protection, + _is_desktop_owned_backend, _is_electron_packaged_web_dist, _maybe_setup_dashboard_auth_interactively, _read_ssh_session_token_file, @@ -2607,10 +2608,7 @@ def _dashboard_sanitize_desktop_env(headless_backend) -> None: HERMES_DASHBOARD_SESSION_TOKEN, which the terminal pane never receives and the terminal tool's env policy strips from agent children. """ - desktop_owned_child = ( - os.environ.get("HERMES_DESKTOP") == "1" - and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")) - ) + desktop_owned_child = _is_desktop_owned_backend() if ( not headless_backend and not desktop_owned_child @@ -2689,7 +2687,7 @@ def _dashboard_prepare_runtime(args, headless_backend) -> bool: # wait_for_mcp_discovery covers a server still connecting at first turn. # A standalone (non-Desktop) dashboard may sit idle and unvisited for days # (#58733): it arms discovery instead and the first /api/ws client fires it. - desktop = os.environ.get("HERMES_DESKTOP") == "1" + desktop = _is_desktop_owned_backend() if headless_backend and desktop: return True try: diff --git a/hermes_cli/main_dashboard.py b/hermes_cli/main_dashboard.py index fcc927dd88..a63b6e88ff 100644 --- a/hermes_cli/main_dashboard.py +++ b/hermes_cli/main_dashboard.py @@ -751,6 +751,20 @@ def _is_electron_packaged_web_dist(path: str) -> bool: return "app.asar" in path.replace("\\", "/") +def _is_desktop_owned_backend() -> bool: + """Whether this process is the backend that Desktop spawned and owns. + + ``HERMES_DESKTOP`` is a marker inherited by shells launched from Desktop; + it is not ownership proof. Desktop gives only its backend a fresh session + credential, so requiring both values keeps inherited terminal commands on + the normal one-host-backend path. + """ + return ( + os.environ.get("HERMES_DESKTOP") == "1" + and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN")) + ) + + def _host_backend_attachment(): """Live host serve/dashboard record to attach to, or ``None``. @@ -826,7 +840,7 @@ def _attach_to_host_backend(args, headless_backend: bool) -> None: Returns normally — leaving the caller to BIND — when no owner answers. """ - if getattr(args, "isolated", False) or os.environ.get("HERMES_DESKTOP") == "1": + if getattr(args, "isolated", False) or _is_desktop_owned_backend(): return record = _host_backend_attachment() if record is None: @@ -893,7 +907,7 @@ def _route_named_profile_dashboard( _launch_profile in ("default", "custom") or getattr(args, "isolated", False) or getattr(args, "open_profile", "") - or os.environ.get("HERMES_DESKTOP") == "1" + or _is_desktop_owned_backend() ): return diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py index f92068da97..2e79f55f23 100644 --- a/tests/hermes_cli/test_dashboard_unified_launch.py +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -62,12 +62,38 @@ class TestUnifiedDashboardRouting: assert env.get("HERMES_HOME") == str(get_default_hermes_root()) + def test_inherited_desktop_marker_routes_named_profile_to_machine_dashboard( + self, main_mod, monkeypatch + ): + """A Desktop shell is not its credential-bearing backend child.""" + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" + ) + monkeypatch.setattr(main_dashboard, "_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 + assert "-p" in execs[0][1] and execs[0][1][execs[0][1].index("-p") + 1] == "default" + + def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch): """A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT reroute into the machine dashboard. The reroute re-execs as the default profile and exits, so the desktop never sees a ready backend → boot loop. The guard keeps desktop pool backends per-profile.""" monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-spawn-token") monkeypatch.setattr( "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" ) @@ -110,5 +136,3 @@ class TestInteractiveDashboardAuthSetup: assert "configured external dashboard.public_url" in output - - diff --git a/tests/hermes_cli/test_serve_host_attach.py b/tests/hermes_cli/test_serve_host_attach.py index eccfca3258..6d77c67a60 100644 --- a/tests/hermes_cli/test_serve_host_attach.py +++ b/tests/hermes_cli/test_serve_host_attach.py @@ -91,6 +91,31 @@ def test_second_serve_attaches_to_the_live_host_backend(host_dir, owner, capsys) assert f"port {owner.port}" in capsys.readouterr().out +def test_inherited_desktop_flag_without_spawn_credential_still_attaches(host_dir, owner, monkeypatch): + """A terminal spawned by Desktop inherits its marker, not Desktop ownership. + + Only the Desktop backend receives the per-spawn session credential. A bare + marker must therefore preserve the one-host-backend attach invariant. + """ + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + _publish(hr.process_create_time(), port=owner.port) + + with pytest.raises(SystemExit) as exc: + _attach_to_host_backend(_args(), headless_backend=True) + + assert exc.value.code == 0 + + +def test_desktop_owned_backend_keeps_its_separate_lifecycle(host_dir, owner, monkeypatch): + """Desktop's credential-bearing backend does not attach to the host owner.""" + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-spawn-token") + _publish(hr.process_create_time(), port=owner.port) + + assert _attach_to_host_backend(_args(), headless_backend=True) is None + + def test_stale_record_is_ignored_and_the_launch_proceeds(host_dir): """A record whose creation time does not match the live PID is a recycled PID, not a backend: the launch must fall through and bind, never attach."""