From 19fde8a450debe89056aaaff4f3f435af77fc0b7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:44:30 -0700 Subject: [PATCH] fix(dashboard): compare and spawn the venv interpreter by UNRESOLVED path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the cherry-picked #90030: candidate.resolve() breaks the fix on the standard Linux venv layout, where venv/bin/python is a symlink to the base interpreter. Resolving makes the venv python compare equal to the dependency-less base (so the swap never happens), and returning the resolved target would spawn the bare base interpreter, bypassing pyvenv.cfg — the fix would silently not fix #90026 on the exact platform it was reported from. Compare and return normalized UNRESOLVED paths: the venv path IS the interpreter's identity. Adds the symlink-layout regression test; live-E2E'd with a real dependency-less base runtime. --- hermes_cli/web_server.py | 18 ++++++++++++---- .../test_dashboard_spawn_executable.py | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) 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" + )