diff --git a/contributors/emails/alvaro.sanchez-mariscal@oracle.com b/contributors/emails/alvaro.sanchez-mariscal@oracle.com new file mode 100644 index 000000000000..c8f03aec4612 --- /dev/null +++ b/contributors/emails/alvaro.sanchez-mariscal@oracle.com @@ -0,0 +1,2 @@ +alvarosanchez +# PR #44797 salvage (dashboard TUI Python environment parity) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index dbabf547f26d..1525041c111a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2003,6 +2003,39 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int: return max(1536, sized) if limit_mb > 2048 else sized +def _safe_tui_cwd(env: Optional[dict] = None) -> str: + """Return a stable cwd value for the Node TUI child environment.""" + try: + return os.getcwd() + except FileNotFoundError: + candidate = ((env or {}).get("PWD") or os.environ.get("PWD") or "").strip() + if candidate and Path(candidate).is_dir(): + return candidate + return str(PROJECT_ROOT) + + +def _apply_tui_python_env(env: dict) -> None: + """Seed/repair Python-related env vars shared by CLI and dashboard TUI launches.""" + src_root = str(env.get("HERMES_PYTHON_SRC_ROOT") or "").strip() + if not src_root or not Path(src_root).is_dir(): + env["HERMES_PYTHON_SRC_ROOT"] = str(PROJECT_ROOT) + + cwd = str(env.get("HERMES_CWD") or "").strip() + if not cwd or not Path(cwd).is_dir(): + env["HERMES_CWD"] = _safe_tui_cwd(env) + + python = str(env.get("HERMES_PYTHON") or "").strip() + if os.path.dirname(python): + python_path = Path(python) + if not python_path.is_absolute(): + python_path = Path(env["HERMES_CWD"]) / python_path + python_is_executable = python_path.is_file() and os.access(python_path, os.X_OK) + else: + python_is_executable = bool(shutil.which(python, path=env.get("PATH"))) + if not python_is_executable: + env["HERMES_PYTHON"] = sys.executable + + def _launch_tui( resume_session_id: Optional[str] = None, tui_dev: bool = False, @@ -2036,11 +2069,6 @@ def _launch_tui( ) os.close(active_session_fd) env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file - env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get( - "HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT) - ) - env.setdefault("HERMES_PYTHON", sys.executable) - env.setdefault("HERMES_CWD", os.getcwd()) env.setdefault("NODE_ENV", "development" if tui_dev else "production") wt_info = None @@ -2065,6 +2093,8 @@ def _launch_tui( env["HERMES_CWD"] = wt_info["path"] env["TERMINAL_CWD"] = wt_info["path"] + _apply_tui_python_env(env) + if model: env["HERMES_MODEL"] = model env["HERMES_INFERENCE_MODEL"] = model diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8dee6cce9ec4..1ba9a0bd1112 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -15346,7 +15346,7 @@ def _resolve_chat_argv( dashboard's in-memory gateway runs under the dashboard's own profile, so a profile-scoped chat must spawn its own gateway subprocess. """ - from hermes_cli.main import PROJECT_ROOT, _make_tui_argv + from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv profile_dir: Optional[Path] = None requested = (profile or "").strip() @@ -15360,6 +15360,7 @@ def _resolve_chat_argv( apply_terminal_config_to_env(env=env) except Exception: _log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True) + _apply_tui_python_env(env) env.setdefault("NODE_ENV", "production") # Browser-embedded chat should prefer stable wheel-based scrollback over # native terminal mouse tracking. When mouse tracking is enabled, wheel diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 7c83ff2a5029..87a1e61cd688 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -924,6 +924,44 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): assert env["NODE_ENV"] == "production" +def test_launch_tui_worktree_validates_relative_python_against_final_cwd( + monkeypatch, main_mod, tmp_path +): + import cli as cli_mod + + parent_cwd = tmp_path / "parent" + parent_cwd.mkdir() + worktree = tmp_path / "worktree" + relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name + python_path = worktree / relative_python + python_path.parent.mkdir(parents=True) + os.link(sys.executable, python_path) + captured = {} + + monkeypatch.setenv("HERMES_CWD", str(parent_cwd)) + monkeypatch.setenv("HERMES_PYTHON", str(relative_python)) + monkeypatch.setattr(cli_mod, "_git_repo_root", lambda: None) + monkeypatch.setattr(cli_mod, "_prune_stale_worktrees", lambda _repo: None) + monkeypatch.setattr(cli_mod, "_setup_worktree", lambda: {"path": str(worktree)}) + monkeypatch.setattr(cli_mod, "_cleanup_worktree", lambda _info: None) + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + monkeypatch.setattr( + main_mod.subprocess, + "call", + lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1, + ) + + with pytest.raises(SystemExit): + main_mod._launch_tui(worktree=True) + + assert captured["env"]["HERMES_CWD"] == str(worktree) + assert captured["env"]["HERMES_PYTHON"] == str(relative_python) + + def test_launch_tui_applies_terminal_backend_config( monkeypatch, main_mod, _isolate_hermes_home ): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 691d3796a25c..220fd3537ce9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -7104,6 +7104,106 @@ class TestPtyWebSocket: assert env["COLORTERM"] == "24bit" + def test_resolve_chat_argv_sets_tui_python_environment(self, monkeypatch): + """Dashboard chat gives the Node TUI the same Python env as CLI launches.""" + import hermes_cli.main as main_mod + + monkeypatch.delenv("HERMES_PYTHON_SRC_ROOT", raising=False) + monkeypatch.delenv("HERMES_PYTHON", raising=False) + monkeypatch.delenv("HERMES_CWD", raising=False) + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env is not None + assert env["HERMES_PYTHON_SRC_ROOT"] == str(main_mod.PROJECT_ROOT) + assert env["HERMES_PYTHON"] == sys.executable + assert env["HERMES_CWD"] == os.getcwd() + + def test_resolve_chat_argv_replaces_invalid_tui_python_environment(self, monkeypatch): + """Dashboard chat does not preserve unusable inherited TUI Python env.""" + import hermes_cli.main as main_mod + + monkeypatch.setenv("HERMES_PYTHON_SRC_ROOT", "/definitely/missing/hermes-src") + monkeypatch.setenv("HERMES_PYTHON", "/definitely/missing/python") + monkeypatch.setenv("HERMES_CWD", "/definitely/missing/cwd") + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env is not None + assert env["HERMES_PYTHON_SRC_ROOT"] == str(main_mod.PROJECT_ROOT) + assert env["HERMES_PYTHON"] == sys.executable + assert env["HERMES_CWD"] == os.getcwd() + + def test_resolve_chat_argv_keeps_relative_python_under_tui_cwd( + self, monkeypatch, tmp_path + ): + """Relative Python paths are resolved from the TUI child's cwd.""" + import hermes_cli.main as main_mod + + relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name + python_path = tmp_path / relative_python + python_path.parent.mkdir(parents=True) + os.link(sys.executable, python_path) + monkeypatch.setenv("HERMES_CWD", str(tmp_path)) + monkeypatch.setenv("HERMES_PYTHON", str(relative_python)) + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env is not None + assert env["HERMES_PYTHON"] == str(relative_python) + + def test_tui_python_command_uses_child_path(self, tmp_path): + """Bare Python commands are resolved from the TUI child's PATH.""" + import hermes_cli.main as main_mod + + command = f"hermes-review-python{Path(sys.executable).suffix}" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + executable = bin_dir / command + os.link(sys.executable, executable) + env = { + "HERMES_CWD": str(tmp_path), + "HERMES_PYTHON": command, + "PATH": str(bin_dir), + } + + main_mod._apply_tui_python_env(env) + + assert env["HERMES_PYTHON"] == command + + def test_resolve_chat_argv_falls_back_when_getcwd_is_missing(self, monkeypatch, tmp_path): + """Dashboard chat still starts if the service cwd was deleted.""" + import hermes_cli.main as main_mod + + monkeypatch.delenv("HERMES_CWD", raising=False) + monkeypatch.setenv("PWD", str(tmp_path)) + monkeypatch.setattr(main_mod.os, "getcwd", lambda: (_ for _ in ()).throw(FileNotFoundError())) + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"), + ) + + _argv, _cwd, env = self.ws_module._resolve_chat_argv() + + assert env is not None + assert env["HERMES_CWD"] == str(tmp_path) + def test_resolve_chat_argv_applies_terminal_backend_config( self, monkeypatch, _isolate_hermes_home ):