From a664cf68f59966e65ba3f0b94f121858d18cf879 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Thu, 17 Sep 2026 14:38:20 -0400 Subject: [PATCH] Do not prefer a dialog on a machine with nowhere to draw it config/bash/shell exported SSH_ASKPASS=ksshaskpass with SSH_ASKPASS_REQUIRE=prefer on every machine. `prefer` means ssh and git reach for that dialog BEFORE the terminal prompt, so on a headless server the prompt that would have worked was skipped in favour of a KDE window that cannot open: error: /usr/bin/ksshaskpass died of signal 6 fatal: could not read Username for 'https://git.gbrown.org' A push from a server failed to ASK for a username rather than failing to authenticate. The shell environment is the one half of Panama a server does install, so nothing downstream was going to catch this. The export is now conditional on a session existing to draw in, and on the binary being there to run. The headless branch also CLEARS both variables rather than merely declining to set them: an askpass inherited from a parent that had a display, or from a login predating this guard, is exactly as unusable and a guard that only withholds leaves it in place. A fresh login shell on the repaired server still reported prefer until it cleared them. A desktop is unchanged; a server, a serial console and a plain SSH session fall back to the terminal prompt, which works. tests/setup/headless-askpass-contract pins every branch, the inherited case included, and fails against the previous code and against a guard that only withholds. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- config/bash/shell | 19 +++++- tests/contracts.manifest | 1 + tests/setup/headless-askpass-contract | 83 +++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100755 tests/setup/headless-askpass-contract diff --git a/README.md b/README.md index 15632b3..b31653e 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -191 of them, under `tests/`. `tests/contracts.manifest` classifies every +192 of them, under `tests/`. `tests/contracts.manifest` classifies every contract by the capabilities it needs. Run the hermetic set, or grant a specific external capability when automation needs it: diff --git a/config/bash/shell b/config/bash/shell index e12876a..98bf31c 100644 --- a/config/bash/shell +++ b/config/bash/shell @@ -16,8 +16,23 @@ unset PANAMA_ENV # Editor used by CLI export EDITOR="nvim" export SUDO_EDITOR="$EDITOR" -export SSH_ASKPASS=/usr/bin/ksshaskpass -export SSH_ASKPASS_REQUIRE=prefer +# A graphical askpass, and only where there is a session to draw it in. +# SSH_ASKPASS_REQUIRE=prefer means ssh and git reach for the dialog BEFORE the +# terminal prompt, so on a headless machine the prompt that would have worked +# is skipped in favour of one that cannot open: `git push` then fails reading +# a username rather than asking for it. The desktop keeps the dialog; a +# server, a serial console and a plain SSH session fall back to the terminal. +if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] && [ -x /usr/bin/ksshaskpass ]; then + export SSH_ASKPASS=/usr/bin/ksshaskpass + export SSH_ASKPASS_REQUIRE=prefer +else + # Cleared, not merely left unset. An askpass inherited from a parent that + # did have a display -- an agent's shell, a forwarded environment, a login + # that predates this guard -- is exactly as unusable here as one this file + # set itself, and declining to add it does nothing about a value already in + # the environment. + unset SSH_ASKPASS SSH_ASKPASS_REQUIRE +fi # Define Paths export CARGO_PATH="$HOME/.cargo" diff --git a/tests/contracts.manifest b/tests/contracts.manifest index e9b60fe..559f39a 100644 --- a/tests/contracts.manifest +++ b/tests/contracts.manifest @@ -261,6 +261,7 @@ hermetic tests/setup/firefox-chrome-contract hermetic tests/setup/hardware-contract # Reads the host hardware predicates after exercising fixture sysfs trees. live-host tests/setup/hardware-predicates-contract +hermetic tests/setup/headless-askpass-contract hermetic tests/setup/hooks-contract hermetic tests/setup/interview-contract hermetic tests/setup/launch-or-focus-contract diff --git a/tests/setup/headless-askpass-contract b/tests/setup/headless-askpass-contract new file mode 100755 index 0000000..93eb279 --- /dev/null +++ b/tests/setup/headless-askpass-contract @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# The graphical askpass, and the machines that have no graphics. +# +# config/bash/shell used to export SSH_ASKPASS=ksshaskpass with +# SSH_ASKPASS_REQUIRE=prefer unconditionally. `prefer` means ssh and git reach +# for that dialog BEFORE the terminal prompt, so on a server the prompt that +# would have worked was skipped in favour of a KDE window that cannot open: +# +# error: /usr/bin/ksshaskpass died of signal 6 +# fatal: could not read Username for 'https://...' +# +# A `git push` from a headless box failed to ASK for a username rather than +# failing to authenticate -- and the shell environment is the one half of +# Panama a server does install, so nothing else was going to catch it. +# +# Pinned here: headless sessions never prefer a dialog, and a desktop with the +# binary present still gets one. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shell_file="$repo_dir/config/bash/shell" + +findings=() +note() { findings+=("$1"); } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/home" + +# Sources the shell environment in a sandbox and reports one variable from it. +# HOME is empty so the personal env file is not read, and the session is +# non-interactive with no SSH_CONNECTION so the tmux hand-off cannot fire. +askpass_state() { + env -u DISPLAY -u WAYLAND_DISPLAY -u SSH_CONNECTION -u SSH_ASKPASS \ + -u SSH_ASKPASS_REQUIRE "$@" \ + HOME="$work/home" PANAMA_PATH="$repo_dir" \ + bash -c 'source "$1" >/dev/null 2>&1 + printf "%s|%s" "${SSH_ASKPASS:-}" "${SSH_ASKPASS_REQUIRE:-}"' \ + _ "$shell_file" +} + +# ── Headless is the case that broke ────────────────────────────────────────── +got="$(askpass_state)" +[[ "$got" == "|" ]] \ + || note "a headless session still prefers a graphical askpass (got '$got')" + +got="$(askpass_state SSH_CONNECTION='10.0.0.1 22 10.0.0.2 22')" +[[ "$got" == "|" ]] \ + || note "an SSH session still prefers a graphical askpass (got '$got')" + +# The case the first version of this contract missed: declining to SET the +# variables does nothing about ones already in the environment, inherited from +# a parent that had a display or from a login predating the guard. A fresh +# login shell on the repaired server still reported prefer until the headless +# branch cleared them outright, so the clearing is what gets pinned. +got="$(askpass_state SSH_ASKPASS=/usr/bin/ksshaskpass SSH_ASKPASS_REQUIRE=prefer)" +[[ "$got" == "|" ]] \ + || note "an inherited askpass survived a headless session (got '$got')" + +# ── A desktop still gets its dialog ────────────────────────────────────────── +# Only assertable where the binary exists; saying so beats asserting that a +# machine without KDE installed fails to configure KDE's dialog. +if [[ -x /usr/bin/ksshaskpass ]]; then + got="$(askpass_state WAYLAND_DISPLAY=wayland-0)" + [[ "$got" == "/usr/bin/ksshaskpass|prefer" ]] \ + || note "a Wayland session does not prefer the graphical askpass (got '$got')" + + got="$(askpass_state DISPLAY=:0)" + [[ "$got" == "/usr/bin/ksshaskpass|prefer" ]] \ + || note "an X session does not prefer the graphical askpass (got '$got')" +else + printf 'headless askpass contract: ksshaskpass is not installed, so the desktop half was not verified\n' >&2 +fi + +# ── Report ─────────────────────────────────────────────────────────────────── +if (( ${#findings[@]} > 0 )); then + printf 'headless askpass contract FAILED:\n' >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi +printf 'headless askpass contract passed\n'