"""Graphical password prompts for server-relayed `sudo` commands. The pet has no terminal. When the server relays something like `sudo apt update`, sudo tries to read a password from a tty, finds none (or finds the terminal the pet was launched from, which you're not looking at), and the command fails with no way to answer it. sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper program and reads the password from the helper's stdout instead of a tty. Any GUI prompt that prints what was typed works, so this module finds a real askpass binary if one is installed and otherwise generates a one-line wrapper around zenity/kdialog, which every desktop has one of. Worth being clear about what this changes: the prompt is a *feature*, not just plumbing. Server-relayed commands already run as your desktop user (see server_client.run_local_command); this lets them ask to run as root, and the dialog is the only thing standing between "Bolt decided to run sudo" and it happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs. The parts that decide *what* to run are pure functions so they're tested without a display, a password, or a working sudo. """ from __future__ import annotations import os import re import shutil import stat from pathlib import Path from typing import Callable, Optional from . import config # Real askpass binaries, in preference order. These are purpose-built for # this (they grab the keyboard, hide the input, and don't leave the password # in a process argument), so they win over a generated wrapper. _KNOWN_HELPERS = ( "/usr/bin/ssh-askpass", "/usr/lib/ssh/ssh-askpass", "/usr/lib/openssh/gnome-ssh-askpass3", "/usr/lib/openssh/gnome-ssh-askpass", "/usr/libexec/openssh/ssh-askpass", "/usr/bin/ksshaskpass", "/usr/bin/lxqt-openssh-askpass", ) # Dialog tools we can wrap when no askpass binary exists. Each must print the # typed password to stdout and nothing else. _WRAPPABLE = { "zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null', "kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null', } # `sudo` at the start of the command or right after a shell separator, not # already carrying a flag. Deliberately conservative: a `sudo` inside a # quoted string or a heredoc is left alone, because rewriting it could change # what the command means. _SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)") def add_askpass_flag(command: str) -> str: """Insert `-A` after each bare `sudo`, so it prompts through the helper instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`, `sudo -u bob`) are left exactly as they are — the caller was explicit.""" return _SUDO.sub(r"\1\2 -A\3", command or "") def needs_password_prompt(command: str) -> bool: """True if *command* has a `sudo` that might sit waiting on a dialog. Used to give those commands a longer timeout — 30 seconds is fine for a shell command and nowhere near enough for a human to notice a window, read it, and type a password.""" return bool(_SUDO.search(command or "")) def helper_script(tool_path: str) -> str: """The wrapper script for a dialog *tool_path*. sudo passes its prompt ("[sudo] password for maji:") as $1, which is worth showing — it names the user the password is for.""" name = Path(tool_path).name body = _WRAPPABLE[name].format(tool=tool_path) return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n" def _default_cache_dir() -> Path: base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") return Path(base) / "bolt-pet" def find_helper( configured: str = None, is_executable: Callable[[str], bool] = None, which: Callable[[str], Optional[str]] = None, cache_dir: Path = None, write: bool = True, ) -> Optional[str]: """Path to an askpass helper, or None if the desktop has nothing we can use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass binary, then a generated wrapper around zenity/kdialog. The lookups are injectable so the resolution order is testable on a box with a different set of these installed than yours.""" configured = config.SUDO_ASKPASS_HELPER if configured is None else configured is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK)) which = which or shutil.which if configured: return configured if is_executable(configured) else None for candidate in _KNOWN_HELPERS: if is_executable(candidate): return candidate for tool in _WRAPPABLE: tool_path = which(tool) if not tool_path: continue if not write: return tool_path return _write_wrapper(tool_path, cache_dir or _default_cache_dir()) return None def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]: """Drop the wrapper script somewhere sudo can execute it. Mode 0700: it isn't secret, but it's a thing that pops up a password box, so nobody else on the machine gets to edit it.""" try: cache_dir.mkdir(parents=True, exist_ok=True) script = cache_dir / "askpass.sh" source = helper_script(tool_path) if not script.exists() or script.read_text(encoding="utf-8") != source: script.write_text(source, encoding="utf-8") script.chmod(stat.S_IRWXU) return str(script) except Exception: return None # no prompt is better than a crashed command relay def environment(helper: str, base: dict = None) -> dict: """The subprocess environment with SUDO_ASKPASS pointed at *helper*.""" env = dict(os.environ if base is None else base) env["SUDO_ASKPASS"] = helper return env