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.
This commit is contained in:
ethernet
2026-09-24 13:06:06 -04:00
parent cff2cfd41d
commit 1efd5fe1a5
2 changed files with 10 additions and 3 deletions

View File

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

View File

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