fix(cli): pass TUI Python env from dashboard chat (salvage #44797) (#66581)

* fix: pass TUI Python env from dashboard chat

* fix: share TUI Python env setup

* fix: preserve TUI Python path semantics

* chore: map contributor email for releases

---------

Co-authored-by: AI on behalf of Álvaro Sánchez-Mariscal <alvaro.sanchez-mariscal@oracle.com>
This commit is contained in:
Austin Pickett 2026-07-17 19:04:33 -04:00 committed by GitHub
parent 9803b2fb89
commit 5122ddd478
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 177 additions and 6 deletions

View file

@ -0,0 +1,2 @@
alvarosanchez
# PR #44797 salvage (dashboard TUI Python environment parity)

View file

@ -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

View file

@ -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

View file

@ -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
):

View file

@ -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
):