From 1efd5fe1a5b520e449ad5bb4c8aca58801b184af Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 13:06:06 -0400 Subject: [PATCH] fix(dashboard): report a PM node failure on /api/pty; give E2E sandboxes node The PTY route turned a missing node (SystemExit) into a visible reason, but _make_tui_argv now asks PM for node, and PM's InstallError went uncaught: the socket just closed on an empty screen. Report it through _pty_fail like the other launch failures; PM's own remedy names the fix. The E2E sandbox's prepared PM store carried only uv and python, so every PTY test hit exactly that. Carry node too when the runner's store has it. --- hermes_cli/web_routers/chat_ws.py | 4 ++++ tests/e2e/core/_pm_dependencies.py | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/hermes_cli/web_routers/chat_ws.py b/hermes_cli/web_routers/chat_ws.py index 95ec9dcbbb..00252b9df1 100644 --- a/hermes_cli/web_routers/chat_ws.py +++ b/hermes_cli/web_routers/chat_ws.py @@ -438,6 +438,7 @@ async def _pty_fail(ws: WebSocket, exc: BaseException) -> None: @router.websocket("/api/pty") async def pty_ws(ws: WebSocket) -> None: from hermes_cli.web_server_chat import PTY_REGISTRY, PtyBridge, PtyUnavailableError, _PTY_BRIDGE_AVAILABLE, _RESIZE_RE + from pm.package import InstallError gate = await _ws_gate(ws, "pty") if gate is None: return @@ -503,6 +504,9 @@ async def pty_ws(ws: WebSocket) -> None: except SystemExit as exc: # _make_tui_argv sys.exit(1)s when node/npm is missing await _pty_fail(ws, exc) return + except InstallError as exc: # PM could not provide node; its remedy names the fix + await _pty_fail(ws, exc) + return attach_token = ws.query_params.get("attach") or None registry_resume = raw_resume diff --git a/tests/e2e/core/_pm_dependencies.py b/tests/e2e/core/_pm_dependencies.py index f8056137f3..5c5ea2731d 100644 --- a/tests/e2e/core/_pm_dependencies.py +++ b/tests/e2e/core/_pm_dependencies.py @@ -25,13 +25,16 @@ def _prepare_test_tools() -> tuple[Path, dict] | None: if not (source / "facts.json").is_file(): return None # A system/Nix test interpreter has no selected PM tool store. facts = Facts(source / "facts.json", strict=True) - records = {name: facts.get(name) for name in ("uv", "python")} + # node runs the TUI behind the dashboard's /api/pty; carry it when the + # runner's store has it, or PM in the sandbox reports it not installed. + names = ("uv", "python") + (("node",) if facts.get("node") else ()) + records = {name: facts.get(name) for name in names} python = records["python"] if not python or (source / python["entry"]).resolve() != Path(sys.base_prefix).resolve(): raise AssertionError(f"test interpreter is not from the selected PM store: {source}") target = current_target() lock = Lockfile(lockfile_path()) - verified_tools(("uv", "python"), source_store=source, target=target, lock=lock) + verified_tools(names, source_store=source, target=target, lock=lock) # The source may live under the real ~/.hermes. Per-test guards must never # read it, nor follow a sandbox tool symlink back into it. Copy the verified @@ -44,7 +47,7 @@ def _prepare_test_tools() -> tuple[Path, dict] | None: (prepared / "facts.json").write_text( json.dumps({"schema": 1, "packages": records}), encoding="utf-8" ) - verified_tools(("uv", "python"), source_store=prepared, target=target, lock=lock) + verified_tools(names, source_store=prepared, target=target, lock=lock) except BaseException: shutil.rmtree(prepared, ignore_errors=True) raise