From af391bec6f43b189111b44b879019ddd257426f2 Mon Sep 17 00:00:00 2001 From: Manuel Guido Date: Fri, 29 May 2026 11:08:21 -0300 Subject: [PATCH] fix(uninstall): provide SUDO_ASKPASS so brew cask scripts can prompt when no TTY --- lib/core/sudo.sh | 43 ++++++++++++++++++++++++++++++ lib/uninstall/brew.sh | 37 ++++++++++++++++++++++++++ tests/brew_uninstall.bats | 56 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/lib/core/sudo.sh b/lib/core/sudo.sh index 3f7b8daa..aae6d772 100644 --- a/lib/core/sudo.sh +++ b/lib/core/sudo.sh @@ -310,3 +310,46 @@ stop_sudo_session() { fi MOLE_SUDO_ESTABLISHED="false" } + +# ============================================================================ +# Askpass Helper (for tools that invoke sudo internally) +# ============================================================================ + +# Create a temporary SUDO_ASKPASS helper script. +# +# Some tools (notably Homebrew cask uninstall scripts) invoke `sudo` directly +# for embedded installer packages. When Mole runs these tools under its timeout +# wrapper, the child may not inherit Mole's controlling terminal or its +# tty-scoped sudo timestamp, so sudo fails with "a terminal is required to read +# the password". Pointing SUDO_ASKPASS at this helper lets sudo obtain the +# password through a GUI prompt without a terminal. The password is never +# stored on disk; the helper prompts on demand only when sudo actually needs it. +# +# Prints: absolute path to the helper script on success. +# Returns: 0 on success, 1 if a helper cannot be created (e.g. test mode). +create_sudo_askpass_helper() { + # Never trigger real password or GUI prompts during tests. + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + # The GUI prompt requires osascript (macOS only). + command -v osascript > /dev/null 2>&1 || return 1 + + local helper + helper=$(mktemp "${TMPDIR:-/tmp}/mole-askpass.XXXXXX") || return 1 + + cat > "$helper" << 'ASKPASS' +#!/bin/bash +/usr/bin/osascript \ + -e 'display dialog "Mole needs administrator access to finish removing this app." default answer "" with title "Mole" with icon caution with hidden answer' \ + -e 'text returned of result' 2> /dev/null +ASKPASS + + if ! chmod 700 "$helper" 2> /dev/null; then + rm -f "$helper" + return 1 + fi + + echo "$helper" +} diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index 1000c691..4413fb1b 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -223,9 +223,35 @@ brew_uninstall_cask() { debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" fi + # Homebrew cask uninstall scripts can invoke `sudo` directly for embedded + # installer packages (e.g. Wireshark's path-removal pkg). Mole runs brew + # under a timeout wrapper whose fallback path detaches the controlling + # terminal, so that inner sudo cannot read a password and fails with + # "a terminal is required to read the password". Providing SUDO_ASKPASS lets + # sudo obtain the password through a GUI prompt without a terminal; Homebrew + # automatically adds `sudo -A` when SUDO_ASKPASS is set. sudo only invokes + # the helper when it actually needs a password, so a still-valid timestamp + # avoids any extra prompt. Cleanup is inline (not a RETURN trap) because + # bash 3.2 RETURN traps are not function-scoped and would re-fire with the + # locals out of scope when the caller returns, tripping `set -u`. + local askpass_helper="" prev_askpass_set=0 prev_askpass="" + if command -v create_sudo_askpass_helper > /dev/null 2>&1; then + askpass_helper=$(create_sudo_askpass_helper 2> /dev/null || true) + fi + if [[ -n "$askpass_helper" ]]; then + if [[ -n "${SUDO_ASKPASS+x}" ]]; then + prev_askpass_set=1 + prev_askpass="$SUDO_ASKPASS" + fi + export SUDO_ASKPASS="$askpass_helper" + fi + # Run with timeout to prevent hangs from problematic cask scripts. if [[ -n "${SUDO_USER:-}" ]]; then + # The outer `sudo` scrubs the environment, so SUDO_ASKPASS must be + # re-injected via the inner `env` to reach brew's nested sudo calls. if run_with_timeout "$timeout" sudo -u "$SUDO_USER" env \ + ${askpass_helper:+SUDO_ASKPASS="$askpass_helper"} \ HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ brew uninstall --cask --zap "$cask_name" 2>&1; then uninstall_ok=true @@ -239,6 +265,17 @@ brew_uninstall_cask() { brew_exit=$? fi + # Restore the prior SUDO_ASKPASS state and remove the temp helper inline, + # right after brew has finished, while the locals are still in scope. + if [[ -n "$askpass_helper" ]]; then + rm -f "$askpass_helper" + if [[ "$prev_askpass_set" == "1" ]]; then + export SUDO_ASKPASS="$prev_askpass" + else + unset SUDO_ASKPASS + fi + fi + if [[ "$uninstall_ok" != "true" ]]; then debug_log "brew uninstall timeout or failed with exit code: $brew_exit" # Exit code 124 indicates timeout from run_with_timeout diff --git a/tests/brew_uninstall.bats b/tests/brew_uninstall.bats index 1d1388ec..da12d57c 100644 --- a/tests/brew_uninstall.bats +++ b/tests/brew_uninstall.bats @@ -468,3 +468,59 @@ EOF [ "$status" -eq 0 ] } + +@test "brew_uninstall_cask exports SUDO_ASKPASS so inner sudo prompts work" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/brew.sh" + +debug_log() { :; } +get_path_size_kb() { echo "100"; } +run_with_timeout() { shift; "$@"; } +is_brew_cask_installed() { return 1; } + +# Provide a deterministic helper path instead of triggering a GUI prompt. +create_sudo_askpass_helper() { echo "$HOME/fake-askpass"; } + +brew() { + # Record whether sudo's askpass hook is visible to the cask uninstall. + printf '%s\n' "${SUDO_ASKPASS:-UNSET}" >> "$HOME/askpass.log" + return 0 +} +export -f brew + +# SUDO_ASKPASS must be unset before the call so we can prove brew set it. +unset SUDO_ASKPASS || true +brew_uninstall_cask "demo-cask" + +grep -Fx "$HOME/fake-askpass" "$HOME/askpass.log" +# Helper must not leak into the caller's environment after the call returns. +[[ -z "${SUDO_ASKPASS:-}" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "brew_uninstall_cask restores a pre-existing SUDO_ASKPASS value" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/brew.sh" + +debug_log() { :; } +get_path_size_kb() { echo "100"; } +run_with_timeout() { shift; "$@"; } +is_brew_cask_installed() { return 1; } +create_sudo_askpass_helper() { echo "$HOME/fake-askpass"; } +brew() { return 0; } +export -f brew + +export SUDO_ASKPASS="$HOME/original-askpass" +brew_uninstall_cask "demo-cask" + +[[ "${SUDO_ASKPASS:-}" == "$HOME/original-askpass" ]] +EOF + + [ "$status" -eq 0 ] +}