diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index df2985bdee..0aebafcd36 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4604,12 +4604,22 @@ def _dashboard_spawn_executable() -> str: for rel in ("venv/bin/python", "venv/Scripts/python.exe"): candidate = PROJECT_ROOT / rel if candidate.is_file(): - resolved = candidate.resolve() # Same interpreter → keep sys.executable (preserves the - # docstring's console-ownership behavior verbatim). - if resolved == exe.resolve(): + # docstring's console-ownership behavior verbatim). Compare + # UNRESOLVED normalized paths: a venv's bin/python is + # typically a SYMLINK to the base interpreter, so resolving + # both sides makes the venv python and the dependency-less + # base compare equal — exactly the SSH-runtime case this + # function exists to fix. The unresolved path IS the venv's + # identity (pyvenv.cfg discovery keys off argv0's location). + if os.path.normcase(os.path.normpath(str(candidate))) == ( + os.path.normcase(os.path.normpath(str(exe))) + ): return sys.executable - return str(resolved) + # Return the candidate UNRESOLVED for the same reason: + # invoking the resolved target would bypass pyvenv.cfg and + # run the bare base interpreter again. + return str(candidate) except OSError: pass return sys.executable diff --git a/tests/hermes_cli/test_dashboard_spawn_executable.py b/tests/hermes_cli/test_dashboard_spawn_executable.py index a9780e8743..de1bae8485 100644 --- a/tests/hermes_cli/test_dashboard_spawn_executable.py +++ b/tests/hermes_cli/test_dashboard_spawn_executable.py @@ -67,3 +67,24 @@ class TestDashboardSpawnExecutable: patch.object(sys, "executable", str(base_interp)), ): assert web_server._dashboard_spawn_executable() == str(base_interp) + + def test_venv_symlink_to_base_is_still_preferred_unresolved(self, tmp_path): + """The Linux-standard layout: venv/bin/python is a SYMLINK to the + base interpreter. The chooser must return the UNRESOLVED venv path — + resolving it would compare equal to the base interpreter (missing + the swap) or spawn the base directly (bypassing pyvenv.cfg). This is + the exact layout of the #90026 report.""" + base = tmp_path / "uv-base" / "python" + base.parent.mkdir(parents=True) + base.touch() + venv_py = tmp_path / "venv" / "bin" / "python" + venv_py.parent.mkdir(parents=True) + venv_py.symlink_to(base) + with ( + patch.object(web_server, "PROJECT_ROOT", tmp_path), + patch.object(sys, "executable", str(base)), + ): + chosen = web_server._dashboard_spawn_executable() + assert chosen == str(venv_py), ( + "must return the unresolved venv path, not the symlink target" + )