From c6eeb9b897e9a3cf821de62af3e7208c8a2d71b9 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 8 Jun 2026 11:00:09 -0700 Subject: [PATCH] feat(sandbox): port-in-use consent gate + uniform sudo capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI launch (docker/podman) now pre-flights the noVNC port (6080): if it's already bound, a modal names the holding pid and asks before killing it and proceeding — never a blind `bind: address already in use` collision. Sudo capture is now uniform across every backend installer. Previously only Docker fed the masked-modal password through `--stdin-pass`/`sudo -S`; the captured password was silently dropped for Podman/Multipass and never wired for VirtualBox, and those scripts used bare `sudo` (which hangs/corrupts a raw-mode tty). Now: - shared `run_ensure()` feeds the password to any ensure-*.sh via stdin - podman/multipass/vbox scripts gain the docker sudo ladder (interactive `sudo` / `--yes` `sudo -n` / `--stdin-pass` `sudo -S -p ''`) - podman's apt path wrapped in `sh -c` so one sudo covers update+install - vbox GUI path preflights `sudo_ready()` with actionable guidance, falling back to fail-fast `sudo -n` instead of a tty hang Co-Authored-By: Claude Opus 4.6 --- hh/scripts/ensure-multipass.sh | 16 +++- hh/scripts/ensure-podman.sh | 20 +++- hh/scripts/ensure-vbox.sh | 18 +++- hh/src/app.rs | 144 +++++++++++++++++++++++++++- hh/src/sbx.rs | 167 +++++++++++++++++++++++++-------- 5 files changed, 318 insertions(+), 47 deletions(-) diff --git a/hh/scripts/ensure-multipass.sh b/hh/scripts/ensure-multipass.sh index 007899e..5f3f828 100644 --- a/hh/scripts/ensure-multipass.sh +++ b/hh/scripts/ensure-multipass.sh @@ -14,21 +14,35 @@ # ./ensure-multipass.sh --yes # install without prompting # ./ensure-multipass.sh --check # test only; exit 0 if present, 1 if missing # ./ensure-multipass.sh --plan # show the install plan; change nothing +# ./ensure-multipass.sh --stdin-pass # read a sudo password from stdin (sudo -S) set -uo pipefail ASSUME_YES=0 CHECK_ONLY=0 PLAN_ONLY=0 +STDIN_PASS=0 for arg in "$@"; do case "$arg" in -y|--yes) ASSUME_YES=1 ;; --check) CHECK_ONLY=1 ;; --plan|--dry-run) PLAN_ONLY=1 ;; + # A sudo password is waiting on stdin (the hack-house TUI feeds it). Use + # `sudo -S` so escalation reads that, never the controlling tty. + --stdin-pass) STDIN_PASS=1 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; esac done +# How to escalate (mirrors ensure-docker.sh): +# * --stdin-pass: a password is on stdin → `sudo -S -p ''` (reads stdin, never +# the tty; a raw-mode TUI would corrupt a tty prompt). First sudo caches it. +# * --yes alone: `sudo -n` — fails fast if creds aren't cached, never hangs. +# * interactive shell: plain `sudo` (a real terminal can prompt normally). +SUDO="sudo" +[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n" +[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''" + installed() { command -v multipass >/dev/null 2>&1 && multipass version >/dev/null 2>&1; } mp_version() { multipass version 2>/dev/null | head -1; } @@ -78,7 +92,7 @@ if [[ -z "$install_cmd" ]]; then echo "✖ don't know how to install Multipass here — get it from https://multipass.run/install" >&2 exit 1 fi -[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd" +[[ $need_sudo -eq 1 ]] && install_cmd="$SUDO $install_cmd" [[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2 # --plan: show the real plan and change nothing. diff --git a/hh/scripts/ensure-podman.sh b/hh/scripts/ensure-podman.sh index 8beabc5..f121c8d 100755 --- a/hh/scripts/ensure-podman.sh +++ b/hh/scripts/ensure-podman.sh @@ -17,21 +17,35 @@ # ./ensure-podman.sh --yes # install without prompting # ./ensure-podman.sh --check # test only; exit 0 if present, 1 if missing # ./ensure-podman.sh --plan # show the install plan; change nothing +# ./ensure-podman.sh --stdin-pass # read a sudo password from stdin (sudo -S) set -uo pipefail ASSUME_YES=0 CHECK_ONLY=0 PLAN_ONLY=0 +STDIN_PASS=0 for arg in "$@"; do case "$arg" in -y|--yes) ASSUME_YES=1 ;; --check) CHECK_ONLY=1 ;; --plan|--dry-run) PLAN_ONLY=1 ;; + # A sudo password is waiting on stdin (the hack-house TUI feeds it). Use + # `sudo -S` so escalation reads that, never the controlling tty. + --stdin-pass) STDIN_PASS=1 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; esac done +# How to escalate (mirrors ensure-docker.sh): +# * --stdin-pass: a password is on stdin → `sudo -S -p ''` (reads stdin, never +# the tty; a raw-mode TUI would corrupt a tty prompt). First sudo caches it. +# * --yes alone: `sudo -n` — fails fast if creds aren't cached, never hangs. +# * interactive shell: plain `sudo` (a real terminal can prompt normally). +SUDO="sudo" +[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n" +[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''" + installed() { command -v podman >/dev/null 2>&1 && podman --version >/dev/null 2>&1; } pm_version() { podman --version 2>/dev/null | head -1; } @@ -67,7 +81,9 @@ manual_note="" case "$(uname -s)" in Linux) if command -v apt-get >/dev/null 2>&1; then - install_cmd="apt-get update && apt-get install -y podman" + # Wrapped in `sh -c` so a single `$SUDO …` escalation covers BOTH the + # update and the install (a bare `$SUDO a && b` would only sudo `a`). + install_cmd="sh -c 'apt-get update && apt-get install -y podman'" plan_cmd="apt-cache policy podman" need_sudo=1 elif command -v dnf >/dev/null 2>&1; then @@ -99,7 +115,7 @@ if [[ -z "$install_cmd" ]]; then echo "✖ don't know how to install Podman here — get it from https://podman.io/docs/installation" >&2 exit 1 fi -[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd" +[[ $need_sudo -eq 1 ]] && install_cmd="$SUDO $install_cmd" [[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2 # --plan: show the real plan and change nothing. diff --git a/hh/scripts/ensure-vbox.sh b/hh/scripts/ensure-vbox.sh index 672e4d9..b342d93 100755 --- a/hh/scripts/ensure-vbox.sh +++ b/hh/scripts/ensure-vbox.sh @@ -12,21 +12,35 @@ # ./ensure-vbox.sh --yes # install without prompting (used by --install) # ./ensure-vbox.sh --check # test only; exit 0 if present, 1 if missing # ./ensure-vbox.sh --plan # show the install/download plan; change nothing +# ./ensure-vbox.sh --stdin-pass # read a sudo password from stdin (sudo -S) set -uo pipefail ASSUME_YES=0 CHECK_ONLY=0 PLAN_ONLY=0 +STDIN_PASS=0 for arg in "$@"; do case "$arg" in -y|--yes) ASSUME_YES=1 ;; --check) CHECK_ONLY=1 ;; --plan|--dry-run) PLAN_ONLY=1 ;; + # A sudo password is waiting on stdin (the hack-house TUI feeds it). Use + # `sudo -S` so escalation reads that, never the controlling tty. + --stdin-pass) STDIN_PASS=1 ;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; esac done +# How to escalate (mirrors ensure-docker.sh): +# * --stdin-pass: a password is on stdin → `sudo -S -p ''` (reads stdin, never +# the tty; a raw-mode TUI would corrupt a tty prompt). First sudo caches it. +# * --yes alone: `sudo -n` — fails fast if creds aren't cached, never hangs. +# * interactive shell: plain `sudo` (a real terminal can prompt normally). +SUDO="sudo" +[[ $ASSUME_YES -eq 1 ]] && SUDO="sudo -n" +[[ $STDIN_PASS -eq 1 ]] && SUDO="sudo -S -p ''" + # HH_VBOX_FORCE_MISSING=1 lets a demo exercise the missing→install path without # actually uninstalling anything (the probe is the single source of truth). installed() { @@ -88,7 +102,9 @@ if [[ -z "$install_cmd" ]]; then echo "✖ don't know how to install VirtualBox here — get it from https://www.virtualbox.org/wiki/Downloads" >&2 exit 1 fi -[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd" +[[ $need_sudo -eq 1 ]] && install_cmd="$SUDO $install_cmd" +# The plan path is only ever run interactively (the TUI never asks for --plan), so +# a plain `sudo` tty prompt is fine there. [[ $plan_sudo -eq 1 ]] && plan_cmd="sudo $plan_cmd" # Secure Boot needs the vboxdrv kernel module signed/enrolled (MOK) or it won't diff --git a/hh/src/app.rs b/hh/src/app.rs index 05abf1b..ca84e86 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -197,6 +197,31 @@ pub struct SudoPrompt { pub pending: PendingSudoLaunch, } +/// Launch parameters captured when a GUI sandbox's noVNC port is already bound +/// by another process. Held aside while the port-consent modal asks whether to +/// kill the holder; the launch fires once the owner confirms. Carries the +/// (already-collected) sudo password, if any, so the gate works on both the +/// no-sudo and sudo-submit launch paths. +pub struct PendingGuiLaunch { + pub backend: sbx::Backend, + pub image: String, + pub members: Vec, + pub rows: u16, + pub cols: u16, + pub start_daemon: bool, + pub install_first: bool, + pub gui: bool, + pub password: Option, +} + +/// Active "port in use — kill it?" consent prompt for a GUI sandbox launch. +/// While `Some`, y/n keystrokes resolve this instead of feeding chat. +pub struct PortPrompt { + pub pid: i32, + pub holder: String, + pub pending: PendingGuiLaunch, +} + pub struct App { pub me: String, pub lines: Vec, @@ -260,6 +285,10 @@ pub struct App { /// and creds aren't cached). While `Some`, keystrokes feed this masked /// buffer instead of chat — the secret never leaves the client. pub sudo_prompt: Option, + /// Active "noVNC port already in use — kill the holder?" consent prompt for a + /// GUI sandbox launch (None unless the port is busy). While `Some`, y/n keys + /// resolve it instead of feeding chat. + pub port_prompt: Option, } impl App { @@ -296,6 +325,7 @@ impl App { layout: Layout::default(), focused_pane: None, sudo_prompt: None, + port_prompt: None, } } @@ -1025,6 +1055,32 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme app.sudo_prompt.take().unwrap(); if password.is_empty() { app.sys("⛧ cancelled — empty password"); + } else if let Some(h) = + if pending.gui { sbx::port_holder(sbx::GUI_PORT) } else { None } + { + // Root's now authenticated, but the noVNC port + // is taken — carry the password into the + // port-consent gate so the launch can still fire + // (with sudo) once the holder is cleared. + app.sys(format!( + "⚠ port {} (noVNC) in use by pid {} ({}) — kill it and proceed? [y/N] · Esc cancels", + sbx::GUI_PORT, h.pid, h.name + )); + app.port_prompt = Some(PortPrompt { + pid: h.pid, + holder: h.name, + pending: PendingGuiLaunch { + backend: pending.backend, + image: pending.image, + members: pending.members, + rows: pending.rows, + cols: pending.cols, + start_daemon: pending.start_daemon, + install_first: pending.install_first, + gui: pending.gui, + password: Some(password), + }, + }); } else { launching = true; app.sys("🔒 authenticating + launching…"); @@ -1061,6 +1117,50 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } _ => {} } + } else if app.port_prompt.is_some() { + // GUI-launch port-consent modal: the noVNC port is held + // by another process. y/Y kills the named pid then fires + // the held launch; n/N/Esc aborts. Swallows keys so the + // y/n never lands in chat. + match k.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let PortPrompt { pid, holder, pending } = + app.port_prompt.take().unwrap(); + app.sys(format!( + "⛧ killing {holder} (pid {pid}) to free port {}…", + sbx::GUI_PORT + )); + if sbx::kill_port_holder(sbx::GUI_PORT, pid) { + launching = true; + app.sys("🖥 port freed — launching…"); + spawn_launch( + pending.backend, + pending.image, + app.me.clone(), + pending.members, + pending.rows, + pending.cols, + pending.start_daemon, + pending.install_first, + pending.gui, + pending.password, + pty_tx.clone(), + broker_tx.clone(), + app_tx.clone(), + ); + } else { + app.sys(format!( + "⚠ couldn't free port {} — launch aborted", + sbx::GUI_PORT + )); + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.port_prompt = None; + app.sys("⛧ launch aborted — port left untouched"); + } + _ => {} + } } else if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('x')) && !app.driving @@ -2055,6 +2155,29 @@ fn handle_command( }, }); app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room."); + } else if let Some(h) = if gui { sbx::port_holder(sbx::GUI_PORT) } else { None } { + // The noVNC port is already bound (a stale sandbox, a + // leftover websockify, etc.). Don't blindly collide — + // ask the owner whether to kill the holder, then launch. + app.sys(format!( + "⚠ port {} (noVNC) in use by pid {} ({}) — kill it and proceed? [y/N] · Esc cancels", + sbx::GUI_PORT, h.pid, h.name + )); + app.port_prompt = Some(PortPrompt { + pid: h.pid, + holder: h.name, + pending: PendingGuiLaunch { + backend, + image, + members, + rows, + cols, + start_daemon, + install_first, + gui, + password: None, + }, + }); } else { *launching = true; if install_first { @@ -2867,6 +2990,16 @@ fn launch_vbox_gui( )); return; } + // Installing VirtualBox needs root. This GUI path runs off-thread with no + // in-TUI password modal, so it relies on `sudo -n` (cached creds). If they + // aren't cached the apt step would fail fast mid-launch — preempt that with + // a clear, actionable message rather than a cryptic install error. + if needs_install && !sbx::sudo_ready() { + app.err(format!( + "installing VirtualBox needs sudo, but it isn't cached. Run `!sudo -v` here (caches your password ~15 min), then retry `/sbx vbox gui {vm} yes`." + )); + return; + } spawn_vm_execute(vm, conflicts, needs_install, needs_import, app, app_tx, out_tx, room); } @@ -2914,7 +3047,12 @@ fn spawn_vm_execute( sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?; } if needs_install && !sbx::vbox_installed() { - sbx::ensure_vbox_install().map_err(|e| format!("install failed: {e}"))?; + // VirtualBox install needs root. No in-TUI password was captured + // on this path, so `ensure_vbox_install(None)` uses `sudo -n`: + // it succeeds if creds are cached (the confirm step asks the user + // to run `sudo -v` first) and fails fast with a clear message + // otherwise — never hanging on a tty prompt the TUI can't host. + sbx::ensure_vbox_install(None).map_err(|e| format!("install failed: {e}"))?; } // Pull the shared appliance in if the VM still isn't registered. if needs_import && !sbx::vm_registered(&vm) { @@ -2968,8 +3106,8 @@ fn spawn_launch( let pw = password.clone(); let res = tokio::task::spawn_blocking(move || match backend { sbx::Backend::Docker => sbx::ensure_docker_install(pw), - sbx::Backend::Podman => sbx::ensure_podman_install(), - sbx::Backend::Multipass => sbx::ensure_multipass_install(), + sbx::Backend::Podman => sbx::ensure_podman_install(pw), + sbx::Backend::Multipass => sbx::ensure_multipass_install(pw), _ => Ok(()), }) .await; diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index 2c3f1aa..38fcb02 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -32,6 +32,94 @@ const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh /// server (:1 / 5901) to this port via websockify; the owner opens it in a browser. pub const GUI_PORT: u16 = 6080; +/// A process currently bound to a TCP port we want to use. +pub struct PortHolder { + pub pid: i32, + pub name: String, +} + +/// Find the process listening on `port` (host loopback/any), if any. +/// +/// Parses `ss -H -ltnp` — one listening socket per line, e.g. +/// `LISTEN 0 4096 127.0.0.1:6080 0.0.0.0:* users:(("websockify",pid=3395944,fd=3))`. +/// We match the local-address column (index 3) ending in `:port` and pull the +/// first `("name",pid=N` out of the `users:(...)` column. Returns `None` if the +/// port is free or `ss` can't report the owner (no `-p` perms → no name/pid). +pub fn port_holder(port: u16) -> Option { + let out = Command::new("ss") + .args(["-H", "-ltnp"]) + .stderr(Stdio::null()) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let suffix = format!(":{port}"); + for line in text.lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 4 { + continue; + } + // Local address is the 4th column (Recv-Q is dropped by some ss builds, + // so guard by scanning for the column that ends in `:`). + let local_match = cols.iter().any(|c| c.ends_with(&suffix)); + if !local_match { + continue; + } + // users:(("websockify",pid=3395944,fd=3),...) + let users = match line.split_once("users:(") { + Some((_, rest)) => rest, + None => continue, + }; + let name = users + .split_once("(\"") + .and_then(|(_, r)| r.split_once('"')) + .map(|(n, _)| n.to_string()) + .unwrap_or_else(|| "?".to_string()); + let pid = users + .split_once("pid=") + .and_then(|(_, r)| { + let end = r.find(|c: char| !c.is_ascii_digit()).unwrap_or(r.len()); + r[..end].parse::().ok() + }); + if let Some(pid) = pid { + return Some(PortHolder { pid, name }); + } + } + None +} + +/// Kill the process holding `port` and wait for the port to free up. +/// +/// Sends SIGTERM first (graceful), then SIGKILL if it lingers, polling +/// `port_holder` until the port is free or a short timeout elapses. Returns +/// `true` once the port is free. Used by the GUI-launch consent gate after the +/// owner confirms killing the named pid. +pub fn kill_port_holder(port: u16, pid: i32) -> bool { + let _ = Command::new("kill") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + for _ in 0..15 { + if port_holder(port).is_none() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + // Still bound after SIGTERM — escalate to SIGKILL. + let _ = Command::new("kill") + .args(["-9", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + for _ in 0..15 { + if port_holder(port).is_none() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + port_holder(port).is_none() +} + /// Is the `docker` binary installed? (`docker --version` succeeds.) This is a /// weaker check than `docker_daemon_up`: the CLI can be present while the daemon /// is down. We need both before a Docker sandbox can launch. @@ -98,18 +186,25 @@ pub fn sudo_ready() -> bool { .unwrap_or(false) } -/// Run `ensure-docker.sh --yes` with the given extra args, optionally feeding a -/// sudo password to the script's `sudo -S` via stdin. +/// Run an `ensure-*.sh` installer `--yes` with the given extra args, optionally +/// feeding a sudo password to the script's `sudo -S` via stdin. Shared by every +/// backend installer (docker/podman/multipass/vbox) so sudo capture is uniform. /// -/// Secret handling: the password (if any) is written to the child's stdin and -/// then the local buffer is wiped. It only ever travels parent→child stdin; it -/// is NEVER echoed, logged, or surfaced — sudo never prints the password, so the -/// captured stderr (used for error messages) can't contain it. With no password -/// we close stdin and the script uses `sudo -n` (fails fast if creds aren't -/// cached) so it can never block on an interactive tty prompt. -fn run_ensure_docker(extra: &[&str], password: Option) -> Result<(bool, String)> { +/// Sudo ladder (the scripts honour all three): +/// * password present ⇒ `--stdin-pass` ⇒ the script uses `sudo -S -p ''`, +/// reading the secret from stdin — never the controlling tty (a raw-mode TUI +/// would corrupt a tty prompt). The first sudo caches the credential. +/// * no password ⇒ stdin closed; the script's `--yes` selects `sudo -n`, which +/// fails fast (clear error) if creds aren't cached rather than hanging on a +/// tty prompt the TUI can't host. +/// +/// Secret handling: the password (if any) only ever travels parent→child stdin; +/// the local buffer is wiped immediately after. It is NEVER echoed, logged, or +/// surfaced — sudo never prints the password, so the captured stderr (used for +/// error messages) can't contain it. +fn run_ensure(script: &str, extra: &[&str], password: Option) -> Result<(bool, String)> { let mut cmd = Command::new("bash"); - cmd.arg(ENSURE_DOCKER).arg("--yes"); + cmd.arg(script).arg("--yes"); for a in extra { cmd.arg(a); } @@ -119,7 +214,9 @@ fn run_ensure_docker(extra: &[&str], password: Option) -> Result<(bool, cmd.stdin(Stdio::null()); } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = cmd.spawn().context("running ensure-docker.sh")?; + let mut child = cmd + .spawn() + .with_context(|| format!("running {script}"))?; if let Some(mut pw) = password { if let Some(mut stdin) = child.stdin.take() { // Feed the password to the first `sudo -S`; it caches the credential @@ -134,7 +231,9 @@ fn run_ensure_docker(extra: &[&str], password: Option) -> Result<(bool, } pw.clear(); } - let out = child.wait_with_output().context("ensure-docker.sh")?; + let out = child + .wait_with_output() + .with_context(|| format!("{script}"))?; Ok((out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned())) } @@ -142,7 +241,7 @@ fn run_ensure_docker(extra: &[&str], password: Option) -> Result<(bool, /// ready. With `password`, escalation goes through `sudo -S` (read from stdin); /// without it the script uses `sudo -n` and fails fast if creds aren't cached. fn start_docker_daemon(password: Option) -> Result<()> { - let (ok, err) = run_ensure_docker(&[], password)?; + let (ok, err) = run_ensure(ENSURE_DOCKER, &[], password)?; if !ok { let last = err .lines() @@ -158,7 +257,7 @@ fn start_docker_daemon(password: Option) -> Result<()> { /// job (they passed `install`); the script is idempotent if Docker is present. /// `password` feeds `sudo -S` as above. pub fn ensure_docker_install(password: Option) -> Result<()> { - let (ok, err) = run_ensure_docker(&["--install"], password)?; + let (ok, err) = run_ensure(ENSURE_DOCKER, &["--install"], password)?; if !ok { let last = err.lines().last().unwrap_or("could not install Docker"); anyhow::bail!("{last}"); @@ -169,15 +268,11 @@ pub fn ensure_docker_install(password: Option) -> Result<()> { /// Install Podman via `ensure-podman.sh --yes` (apt + a rootless subuid/subgid /// preflight). Consent is the caller's job (they passed `install`); the script is /// idempotent if Podman is already present. Daemonless — nothing to start after. -/// Returns the script's last error line on failure. -pub fn ensure_podman_install() -> Result<()> { - let out = Command::new("bash") - .arg(ENSURE_PODMAN) - .arg("--yes") - .output() - .context("running ensure-podman.sh")?; - if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr); +/// `password` feeds the script's `sudo -S` (apt needs root); without it the +/// script's `sudo -n` fails fast rather than hanging. Returns the last error line. +pub fn ensure_podman_install(password: Option) -> Result<()> { + let (ok, err) = run_ensure(ENSURE_PODMAN, &[], password)?; + if !ok { let last = err.lines().last().unwrap_or("could not install Podman"); anyhow::bail!("{last}"); } @@ -199,14 +294,9 @@ pub fn multipass_installed() -> bool { /// install (the only supported channel). Consent is the caller's job; the /// script is idempotent if Multipass is already present. Returns the script's /// last error line on failure (e.g. snapd missing, or needs sudo). -pub fn ensure_multipass_install() -> Result<()> { - let out = Command::new("bash") - .arg(ENSURE_MULTIPASS) - .arg("--yes") - .output() - .context("running ensure-multipass.sh")?; - if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr); +pub fn ensure_multipass_install(password: Option) -> Result<()> { + let (ok, err) = run_ensure(ENSURE_MULTIPASS, &[], password)?; + if !ok { let last = err.lines().last().unwrap_or("could not install Multipass"); anyhow::bail!("{last}"); } @@ -241,15 +331,12 @@ pub fn vbox_version() -> Option { /// Install VirtualBox via `ensure-vbox.sh --yes`. Consent is the caller's job /// (they passed `--install`); detection is the script's (idempotent if present). -/// Returns the script's last error line on failure (e.g. needs sudo). -pub fn ensure_vbox_install() -> Result<()> { - let out = Command::new("bash") - .arg(ENSURE_VBOX) - .arg("--yes") - .output() - .context("running ensure-vbox.sh")?; - if !out.status.success() { - let err = String::from_utf8_lossy(&out.stderr); +/// `password` feeds the script's `sudo -S` (apt needs root); without it the +/// script's `sudo -n` fails fast rather than hanging on a tty prompt the TUI +/// can't host. Returns the script's last error line on failure. +pub fn ensure_vbox_install(password: Option) -> Result<()> { + let (ok, err) = run_ensure(ENSURE_VBOX, &[], password)?; + if !ok { let last = err.lines().last().unwrap_or("could not install VirtualBox"); anyhow::bail!("{last}"); }