From 7519df1695fd2b2d22c6155301bb71020c982571 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Wed, 3 Jun 2026 10:41:32 -0700 Subject: [PATCH] feat(sbx): VirtualBox detect-first install + local GUI VM launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate VirtualBox as a local facility rather than a shared-PTY backend: a Windows guest has no shell to relay, so the honest fit is launching the VM's GUI on the caller's own machine (the "share a VM, run it locally" path) — no display is relayed to the room, so zero-knowledge is untouched. - ensure-vbox.sh: detect-first installer mirroring ensure-docker.sh; --check, --plan (real apt --simulate download plan, no changes), --yes; apt/dnf/ pacman/brew/winget; Secure Boot MOK warning. HH_VBOX_FORCE_MISSING lets a demo exercise the missing->install path without uninstalling. - sbx.rs: vbox_installed/vbox_version/list_vms/vm_running/gui_launch + ensure_vbox_install. - app.rs: /sbx vms (detect + list) and /sbx gui [--install] (detect-first then startvm --type gui); /sbx launch virtualbox steers to /sbx gui. - ui.rs help: /sbx vms and /sbx gui entries. Co-Authored-By: Claude Opus 4.6 --- hh/ensure-vbox.sh | 131 ++++++++++++++++++++++++++++++++++++++++++++++ hh/src/app.rs | 70 ++++++++++++++++++++++++- hh/src/sbx.rs | 98 ++++++++++++++++++++++++++++++++++ hh/src/ui.rs | 2 + 4 files changed, 299 insertions(+), 2 deletions(-) create mode 100755 hh/ensure-vbox.sh diff --git a/hh/ensure-vbox.sh b/hh/ensure-vbox.sh new file mode 100755 index 0000000..672e4d9 --- /dev/null +++ b/hh/ensure-vbox.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# ensure-vbox.sh — make sure VirtualBox is installed before /sbx launch virtualbox +# or /sbx gui . +# +# Detect-first, never silent: if VirtualBox is already present this exits 0 and +# changes nothing. If it's missing it prints the EXACT command it would run and +# only installs with explicit consent (--yes). --plan shows a real, no-change +# download plan (apt's own --simulate) so you can see what would be fetched. +# +# usage: +# ./ensure-vbox.sh # interactive: prompt before installing +# ./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 +set -uo pipefail + +ASSUME_YES=0 +CHECK_ONLY=0 +PLAN_ONLY=0 +for arg in "$@"; do + case "$arg" in + -y|--yes) ASSUME_YES=1 ;; + --check) CHECK_ONLY=1 ;; + --plan|--dry-run) PLAN_ONLY=1 ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "✖ unknown arg: $arg" >&2; exit 2 ;; + esac +done + +# 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() { + [[ "${HH_VBOX_FORCE_MISSING:-0}" = "1" ]] && return 1 + command -v VBoxManage >/dev/null 2>&1 && VBoxManage --version >/dev/null 2>&1 +} + +vbox_version() { VBoxManage --version 2>/dev/null | head -1; } + +# Already present: report and stop (idempotent). --plan still prints the plan. +if installed; then + if [[ $PLAN_ONLY -ne 1 ]]; then + [[ $CHECK_ONLY -eq 1 ]] || echo "VirtualBox already installed ($(vbox_version)) — nothing to do" >&2 + exit 0 + fi +fi +[[ $CHECK_ONLY -eq 1 ]] && exit 1 + +# Work out how to install on this platform, and the matching --simulate/plan cmd. +# The plan command is deliberately runnable WITHOUT root where the tool allows it +# (apt --simulate does), so the download plan can be shown with zero privilege. +install_cmd="" +plan_cmd="" +need_sudo=0 +plan_sudo=0 +manual_note="" +case "$(uname -s)" in + Linux) + if command -v apt-get >/dev/null 2>&1; then + install_cmd="apt-get install -y virtualbox" + plan_cmd="apt-get install --simulate virtualbox" # no root needed + need_sudo=1 + elif command -v dnf >/dev/null 2>&1; then + install_cmd="dnf install -y VirtualBox" + plan_cmd="dnf install --assumeno VirtualBox" + need_sudo=1; plan_sudo=1 + elif command -v pacman >/dev/null 2>&1; then + install_cmd="pacman -S --noconfirm virtualbox" + plan_cmd="pacman -S --print virtualbox" + need_sudo=1; plan_sudo=1 + fi + ;; + Darwin) + if command -v brew >/dev/null 2>&1; then + install_cmd="brew install --cask virtualbox" + plan_cmd="brew info --cask virtualbox" + manual_note="macOS: you'll be asked to approve Oracle's kernel extension in System Settings → Privacy & Security, then reboot." + fi + ;; + MINGW*|MSYS*|CYGWIN*) + if command -v winget >/dev/null 2>&1; then + install_cmd="winget install -e --id Oracle.VirtualBox" + plan_cmd="winget show -e --id Oracle.VirtualBox" + fi + ;; +esac + +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" +[[ $plan_sudo -eq 1 ]] && plan_cmd="sudo $plan_cmd" + +# Secure Boot needs the vboxdrv kernel module signed/enrolled (MOK) or it won't +# load. We check and warn rather than fail opaquely — it's a one-time manual step. +if command -v mokutil >/dev/null 2>&1; then + if mokutil --sb-state 2>/dev/null | grep -qi 'SecureBoot enabled'; then + echo "⚠ Secure Boot is ENABLED — after install you must enroll a MOK so the" >&2 + echo " vboxdrv kernel module can load (the installer prompts for a password" >&2 + echo " you re-enter at the blue MOK screen on next reboot)." >&2 + fi +fi +[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2 + +# --plan: show the real download/install plan and change nothing. +if [[ $PLAN_ONLY -eq 1 ]]; then + echo "plan (no changes will be made): $plan_cmd" >&2 + eval "$plan_cmd" + exit 0 +fi + +# Confirmation (skipped with --yes). +if [[ $ASSUME_YES -ne 1 ]]; then + printf 'VirtualBox is not installed. Install it with "%s"? [y/N] ' "$install_cmd" >&2 + read -r reply + case "$reply" in + y|Y|yes|YES) ;; + *) echo "✖ aborted — VirtualBox left uninstalled" >&2; exit 1 ;; + esac +fi + +echo "installing VirtualBox: $install_cmd" >&2 +eval "$install_cmd" || { echo "✖ install failed (sudo password needed? run it in a terminal)" >&2; exit 1; } + +# Confirm the binary is now callable. +if installed; then + echo "VirtualBox is ready ($(vbox_version))" >&2 + exit 0 +fi +echo "✖ install ran but VBoxManage is still not callable — check the install log" >&2 +exit 1 diff --git a/hh/src/app.rs b/hh/src/app.rs index 4a9b09b..925ee8c 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -1191,6 +1191,9 @@ fn handle_command( let start_daemon = args .iter() .any(|a| matches!(*a, "--start" | "--start-daemon" | "-y")); + // VirtualBox isn't a shared-PTY backend — a GUI VM (a Windows + // guest especially) has no shell to relay. Steer to /sbx gui. + let wants_vbox = args.iter().any(|a| matches!(*a, "virtualbox" | "vbox")); let mut pos = args.iter().copied().filter(|a| !a.starts_with('-')); let backend = pos .next() @@ -1201,7 +1204,11 @@ fn handle_command( .map(str::to_string) .unwrap_or_else(|| backend.default_image().to_string()); - if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() + if wants_vbox { + app.sys("VirtualBox VMs run locally in their own GUI — use `/sbx gui ` (list them with `/sbx vms`)"); + } else if backend == sbx::Backend::Docker + && !start_daemon + && !sbx::docker_daemon_up() { app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first"); } else { @@ -1305,8 +1312,67 @@ fn handle_command( }; }); } + Some("vms") => { + let tx = app_tx.clone(); + tokio::spawn(async move { + let res = tokio::task::spawn_blocking(|| { + let ver = sbx::vbox_version().ok_or_else(|| { + "VirtualBox isn't installed — install it with `/sbx gui --install`, or run ./ensure-vbox.sh".to_string() + })?; + let vms = sbx::list_vms().map_err(|e| e.to_string())?; + Ok::<_, String>((ver, vms)) + }) + .await; + let _ = match res { + Ok(Ok((ver, v))) if !v.is_empty() => tx.send(Net::Sys(format!( + "VirtualBox {ver} detected · VMs: {}", + v.join(", ") + ))), + Ok(Ok((ver, _))) => tx.send(Net::Sys(format!( + "VirtualBox {ver} detected · no VMs registered" + ))), + Ok(Err(e)) => tx.send(Net::Err(e)), + Err(e) => tx.send(Net::Err(format!("vms task: {e}"))), + }; + }); + } + Some("gui") => { + let gargs: Vec<&str> = p.collect(); + let install = gargs.iter().any(|a| *a == "--install"); + let name = gargs + .iter() + .copied() + .find(|a| !a.starts_with('-')) + .map(str::to_string); + match name { + None => app.sys("usage: /sbx gui [--install] (list VMs with /sbx vms)"), + Some(vm) => { + app.sys(format!("launching {vm} in the VirtualBox GUI…")); + let tx = app_tx.clone(); + tokio::spawn(async move { + let res = tokio::task::spawn_blocking(move || { + if !sbx::vbox_installed() { + if install { + sbx::ensure_vbox_install() + .map_err(|e| format!("install failed: {e}"))?; + } else { + return Err("VirtualBox isn't installed — retry with `/sbx gui --install` (needs sudo), or run ./ensure-vbox.sh first".to_string()); + } + } + sbx::gui_launch(&vm).map_err(|e| e.to_string()) + }) + .await; + let _ = match res { + Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))), + Ok(Err(e)) => tx.send(Net::Err(e)), + Err(e) => tx.send(Net::Err(format!("gui task: {e}"))), + }; + }); + } + } + } _ => app.sys( - "usage: /sbx launch [local|docker|multipass] [image] · stop · save [label] · load