From 35945ff94852df71159485d8f9f9829da8f0f5ee Mon Sep 17 00:00:00 2001 From: Robert Owens <78518764+reowens@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:56:07 -0700 Subject: [PATCH] fix(qsys-mac-installer): fail fast on headless-SSH .NET installs The .NET runtime installer needs a macOS GUI (Aqua) session for its window; over a headless SSH login Wine's winemac.drv can't create one (nodrv_CreateWindow) and the runtime installs nothing. install_dotnet's landing assertion already catches this, but only after a ~250 MB download and two install attempts. Add a high-precision preflight probe (is_headless_ssh) that refuses up front only when we're confident it's headless: reached over SSH AND no user owns the GUI console (/dev/console == root). A local Terminal, a screen-sharing/VNC session, and the GUI app path all read as NOT headless, so a working setup is never false-blocked. QSYS_ASSUME_GUI=1 overrides. `launchctl managername` was rejected because it returns "Background" in a plain local Terminal too. The install_dotnet assertion stays as the correctness backstop. Adds a hermetic test (mocks console owner via a PATH stat shim, varies SSH_CONNECTION) covering all three paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/qsys-mac-installer/lib/recipe.sh | 19 ++++++++++++++++ packages/qsys-mac-installer/scripts/test.sh | 24 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/qsys-mac-installer/lib/recipe.sh b/packages/qsys-mac-installer/lib/recipe.sh index d2d1c0f..e56c2b6 100644 --- a/packages/qsys-mac-installer/lib/recipe.sh +++ b/packages/qsys-mac-installer/lib/recipe.sh @@ -174,6 +174,24 @@ native_helper_path() { # $1 = override env var name $2 = command name # ---------------------------------------------------------------------------- # Preflight # ---------------------------------------------------------------------------- +# The .NET installer needs a macOS GUI (Aqua) session to create its window; a headless SSH-only +# login can't reach one (Wine's winemac.drv → nodrv_CreateWindow), so the runtime installs NOTHING. +# install_dotnet's landing assertion catches that, but only after a ~250 MB download and two install +# attempts — refuse up front instead. Fire only when we're CONFIDENT it's headless: reached over SSH +# AND no user owns the GUI console (/dev/console == root). Deliberately high-precision so we never +# false-block a working setup — a local Terminal (no SSH_CONNECTION) and a screen-sharing/VNC session +# (which owns the console) both read as NOT headless, as does the ambiguous "SSH in while also logged +# in at the screen" case (left to the install_dotnet backstop). `launchctl managername` was rejected: +# it returns "Background" in a plain local Terminal too, so it would block valid local runs. +# QSYS_ASSUME_GUI=1 forces past the check. The GUI app path always runs inside Aqua with +# SSH_CONNECTION unset (Provisioner → provision.sh from a LaunchServices-launched app), so this never +# fires there — it only ever guards a direct provision.sh/build.sh run over SSH. +is_headless_ssh() { + [ "${QSYS_ASSUME_GUI:-0}" = "1" ] && return 1 + [ -n "${SSH_CONNECTION:-}${SSH_TTY:-}" ] || return 1 + [ "$(stat -f%Su /dev/console 2>/dev/null)" = "root" ] +} + preflight() { [ "$(uname -s)" = "Darwin" ] || die "macOS only." [ "$(uname -m)" = "arm64" ] || warn "Not Apple Silicon — the recipe was proven on arm64 + Rosetta 2 only." @@ -190,6 +208,7 @@ preflight() { command -v curl >/dev/null 2>&1 || die "curl missing." command -v wrestool >/dev/null 2>&1 || warn "icoutils missing (brew install icoutils) — the app will use a generic icon." /usr/bin/pgrep -q oahd 2>/dev/null || warn "Rosetta 2 may not be installed. Install: softwareupdate --install-rosetta --agree-to-license" + if is_headless_ssh; then die "Headless SSH session detected (no user owns the GUI console) — the .NET installer needs a macOS GUI (Aqua) session to create its window, which Wine's winemac.drv can't reach over SSH (nodrv_CreateWindow); provisioning would download the runtime and then fail. Provision from the desktop, or from a Terminal inside a logged-in GUI session. If a GUI session really is reachable (e.g. screen sharing), re-run with QSYS_ASSUME_GUI=1."; fi } # Fail fast before the long pole if the volume can't hold the work. WRAP_HOME must already exist so diff --git a/packages/qsys-mac-installer/scripts/test.sh b/packages/qsys-mac-installer/scripts/test.sh index 9e0fba4..3284ed4 100755 --- a/packages/qsys-mac-installer/scripts/test.sh +++ b/packages/qsys-mac-installer/scripts/test.sh @@ -66,6 +66,30 @@ if git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git -C "$REPO" diff --check -- packages/qsys-mac-installer fi +say "checking headless-SSH pre-flight probe" +# is_headless_ssh() must fire ONLY on SSH + an unowned GUI console (/dev/console == root), and +# QSYS_ASSUME_GUI=1 must force past it. Mock the console owner via a PATH `stat` shim and vary +# SSH_CONNECTION so the check is hermetic (no real session/console needed). +( + # shellcheck source=../lib/recipe.sh + source "$ROOT/lib/recipe.sh" + shim="$(mktemp -d)" + trap 'rm -rf "$shim"' EXIT + export PATH="$shim:$PATH" + console_owner() { printf '#!/bin/bash\necho %s\n' "$1" > "$shim/stat"; chmod +x "$shim/stat"; } + # subshell body so the per-case env never leaks to the next case + probe() ( export SSH_CONNECTION="$1" SSH_TTY="" QSYS_ASSUME_GUI="$2"; is_headless_ssh ) + + console_owner root + probe "1.2.3.4 5 22 22" 0 || die "probe missed a headless SSH session (SSH + console=root)" + if probe "" 0; then die "probe fired on a local (non-SSH) session"; fi + console_owner someuser + if probe "1.2.3.4 5 22 22" 0; then die "probe fired when a user owns the GUI console (e.g. screen sharing)"; fi + console_owner root + if probe "1.2.3.4 5 22 22" 1; then die "QSYS_ASSUME_GUI=1 did not override the probe"; fi +) +say " ok: probe fires only on SSH + unowned console, honors QSYS_ASSUME_GUI" + say "checking process cleanup harness" "$ROOT/scripts/test-process-cleanup.sh"