Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions lib/core/sudo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
37 changes: 37 additions & 0 deletions lib/uninstall/brew.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/brew_uninstall.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
}
Loading