feat(sbx): install Docker/Multipass on demand from /sbx launch
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run

Previously, launching a sandbox on a machine without the backend binary
died with a raw "not installed" error and no path forward. Now the
missing backend can be installed in-line, gated on explicit consent, so
a fresh checkout can go from zero to a running sandbox without leaving
the TUI.

Folded into the existing `/sbx launch` verb rather than a new command
(menu is already dense): `/sbx launch docker|multipass [image] install`.
The `install` token opts in; without it the user gets an actionable
error naming the exact retry. The token is filtered out of positional
image parsing so it never shadows a custom image.

The install runs off-thread inside the existing spawn_launch task,
before provisioning, so the TUI never blocks and the *launching guard is
cleared via BrokerMsg on failure. A fresh Docker install also leaves its
daemon up, so launch proceeds straight through.

Scripts (detect-first, never silent, --plan dry-run, idempotent if
already present):
- ensure-multipass.sh (new): Linux→snap (clear failure if snapd absent),
  macOS→brew cask, Windows→winget.
- ensure-docker.sh: new --install mode using Docker's OFFICIAL,
  GPG-verified apt repo (docker-ce) on Debian/Ubuntu, with dnf
  (Fedora/RHEL) and pacman (Arch) fallbacks. Deliberately avoids piping
  get.docker.com into a root shell. Existing daemon-start path intact.

sbx.rs: docker_installed()/multipass_installed() detectors and
ensure_docker_install()/ensure_multipass_install() wrappers; ENSURE_MULTIPASS const.

ui.rs: help text documents the [install] option on both backends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-07 00:35:19 -07:00
parent 764c827d07
commit c9cdd2feca
5 changed files with 349 additions and 26 deletions
+105 -9
View File
@@ -1,29 +1,100 @@
#!/usr/bin/env bash
# ensure-docker.sh — make sure the Docker daemon is up before /sbx launch docker.
# ensure-docker.sh — make sure Docker is installed AND its daemon is up before
# /sbx launch docker.
#
# Without this, `docker run` fails with "Cannot connect to the Docker daemon"
# and the sandbox launch dies with a raw error. This script detects a dead
# daemon and — after confirmation — starts it, then waits until it's accepting
# connections.
# Two jobs, detect-first and never silent:
# 1. If the docker binary is missing, optionally INSTALL it (--install) from
# Docker's official, GPG-verified apt repo on Debian/Ubuntu (docker-ce),
# with dnf (Fedora/RHEL) and pacman (Arch) fallbacks. We deliberately do
# NOT pipe get.docker.com into a root shell.
# 2. If the daemon is down, start it (and wait until it accepts connections).
#
# Anything already in place is left untouched (idempotent).
#
# usage:
# ./ensure-docker.sh # interactive: prompt before starting the daemon
# ./ensure-docker.sh --yes # start without prompting (used by hack-house --start)
# ./ensure-docker.sh --check # test only; exit 0 if up, 1 if down (no changes)
# ./ensure-docker.sh --yes # start (and install, if --install) without prompting
# ./ensure-docker.sh --install # install Docker if the binary is missing, then start
# ./ensure-docker.sh --check # test only; exit 0 if daemon up, 1 if not (no changes)
# ./ensure-docker.sh --plan # show the install plan; change nothing
set -uo pipefail
ASSUME_YES=0
CHECK_ONLY=0
DO_INSTALL=0
PLAN_ONLY=0
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
--check) CHECK_ONLY=1 ;;
--install) DO_INSTALL=1 ;;
--plan|--dry-run) PLAN_ONLY=1; DO_INSTALL=1 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
esac
done
daemon_up() { docker info >/dev/null 2>&1; }
have_docker() { command -v docker >/dev/null 2>&1; }
# ── Work out how to install Docker on this platform ──────────────────────────
# Emits the ordered list of commands (one per line) to PLAN_LINES; empty if we
# don't know how. Uses Docker's official repo so packages are GPG-verified and
# current (distro packages like docker.io are often stale).
build_install_plan() {
PLAN_LINES=""
[[ "$(uname -s)" == "Linux" ]] || { PLAN_LINES=""; return; }
# shellcheck disable=SC1091
local id="" id_like=""
if [[ -r /etc/os-release ]]; then
id="$(. /etc/os-release; echo "${ID:-}")"
id_like="$(. /etc/os-release; echo "${ID_LIKE:-}")"
fi
case "$id $id_like" in
*debian*|*ubuntu*)
local repo="ubuntu"; [[ "$id" == "debian" ]] && repo="debian"
PLAN_LINES=$(cat <<EOF
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$repo/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=\$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$repo \$(. /etc/os-release && echo \${VERSION_CODENAME}) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
EOF
)
;;
*fedora*|*rhel*|*centos*)
local repo="fedora"; case " $id $id_like " in *rhel*|*centos*) repo="centos";; esac
PLAN_LINES=$(cat <<EOF
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/$repo/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
EOF
)
;;
*arch*)
PLAN_LINES="sudo pacman -S --noconfirm docker"
;;
esac
}
# ── --plan: show the install plan and change nothing ─────────────────────────
if [[ $PLAN_ONLY -eq 1 ]]; then
if have_docker; then
echo "Docker already installed ($(docker --version 2>/dev/null)) — nothing to install" >&2
exit 0
fi
build_install_plan
if [[ -z "$PLAN_LINES" ]]; then
echo "✖ don't know how to install Docker here — see https://docs.docker.com/engine/install/" >&2
exit 1
fi
echo "plan (no changes will be made) — would run:" >&2
printf ' %s\n' "$PLAN_LINES" >&2
exit 0
fi
if daemon_up; then
[[ $CHECK_ONLY -eq 1 ]] || echo "docker daemon already running" >&2
@@ -31,9 +102,34 @@ if daemon_up; then
fi
[[ $CHECK_ONLY -eq 1 ]] && exit 1
if ! command -v docker >/dev/null 2>&1; then
echo "✖ docker is not installed — install Docker first" >&2
if ! have_docker; then
if [[ $DO_INSTALL -ne 1 ]]; then
echo "✖ docker is not installed — re-run with --install to install it" >&2
exit 127
fi
build_install_plan
if [[ -z "$PLAN_LINES" ]]; then
echo "✖ don't know how to install Docker here — see https://docs.docker.com/engine/install/" >&2
exit 1
fi
echo "Docker is not installed. The following will run (Docker's official repo):" >&2
printf ' %s\n' "$PLAN_LINES" >&2
if [[ $ASSUME_YES -ne 1 ]]; then
printf 'Proceed with install? [y/N] ' >&2
read -r reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "✖ aborted — Docker left uninstalled" >&2; exit 1 ;;
esac
fi
while IFS= read -r line; do
[[ -z "$line" ]] && continue
echo "+ $line" >&2
eval "$line" || { echo "✖ install step failed: $line" >&2; exit 1; }
done <<< "$PLAN_LINES"
have_docker || { echo "✖ install ran but docker is still not callable — check the install log" >&2; exit 1; }
echo "Docker installed ($(docker --version 2>/dev/null))" >&2
# On Linux a fresh install usually needs the daemon started below; fall through.
fi
# Work out how to start the daemon on this platform.
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# ensure-multipass.sh — make sure Multipass is installed before /sbx launch multipass.
#
# Detect-first, never silent: if Multipass 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
# plan so you can see what would be fetched.
#
# On Linux, Multipass ships ONLY via snap (no apt/dnf package), so snapd must be
# present; if it isn't, this says so rather than guessing a package name.
#
# usage:
# ./ensure-multipass.sh # interactive: prompt before installing
# ./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
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
installed() { command -v multipass >/dev/null 2>&1 && multipass version >/dev/null 2>&1; }
mp_version() { multipass 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 "Multipass already installed ($(mp_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 no-change plan cmd.
install_cmd=""
plan_cmd=""
need_sudo=0
manual_note=""
case "$(uname -s)" in
Linux)
if command -v snap >/dev/null 2>&1; then
install_cmd="snap install multipass"
plan_cmd="snap info multipass" # no root needed to inspect
need_sudo=1
else
echo "✖ Multipass on Linux requires snap (snapd), which isn't installed." >&2
echo " Install snapd first (e.g. 'sudo apt-get install snapd'), then re-run," >&2
echo " or see https://multipass.run/install for alternatives." >&2
exit 1
fi
;;
Darwin)
if command -v brew >/dev/null 2>&1; then
install_cmd="brew install --cask multipass"
plan_cmd="brew info --cask multipass"
manual_note="macOS: Multipass installs a system helper; you may be prompted for your password."
fi
;;
MINGW*|MSYS*|CYGWIN*)
if command -v winget >/dev/null 2>&1; then
install_cmd="winget install -e --id Canonical.Multipass"
plan_cmd="winget show -e --id Canonical.Multipass"
fi
;;
esac
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"
[[ -n "$manual_note" ]] && echo "$manual_note" >&2
# --plan: show the real 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 'Multipass is not installed. Install it with "%s"? [y/N] ' "$install_cmd" >&2
read -r reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "✖ aborted — Multipass left uninstalled" >&2; exit 1 ;;
esac
fi
echo "installing Multipass: $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 "Multipass is ready ($(mp_version))" >&2
exit 0
fi
echo "✖ install ran but multipass is still not callable — check the install log" >&2
exit 1
+58 -4
View File
@@ -1585,7 +1585,14 @@ fn handle_command(
let start_daemon = args
.iter()
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
// Consent token: if the selected backend's binary is missing,
// `install` opts in to installing it (detect-then-install). It's
// a positional keyword (not the image), so filter it out below.
let want_install = args.iter().any(|a| matches!(*a, "install" | "--install"));
let mut pos = args
.iter()
.copied()
.filter(|a| !a.starts_with('-') && !matches!(*a, "install"));
let first = pos.next();
if matches!(first, Some("virtualbox") | Some("vbox")) {
// VirtualBox runs locally in its own GUI — host & guest each
@@ -1623,18 +1630,47 @@ fn handle_command(
.next()
.map(str::to_string)
.unwrap_or_else(|| backend.default_image().to_string());
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up() {
// Is this backend's binary present? Docker/Multipass can be
// installed on consent; Local needs nothing and vbox is
// handled in its own branch above.
let installed = match backend {
sbx::Backend::Docker => sbx::docker_installed(),
sbx::Backend::Multipass => sbx::multipass_installed(),
_ => true,
};
let token = first.unwrap_or("docker");
if !installed && !want_install {
app.err(format!(
"{} is not installed — retry with `/sbx launch {token} install` to install it (needs sudo)",
backend.label()
));
} else if installed
&& 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 ./scripts/ensure-docker.sh in a terminal first");
} else {
// install_first ⇒ binary missing + consent given: install
// it off-thread before provisioning (a fresh Docker
// install also leaves its daemon up).
let install_first = !installed;
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
if install_first {
app.sys(format!(
"installing {} (needs sudo)… then summoning the sandbox",
backend.label()
));
} else {
app.sys(format!(
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
backend.label()
));
}
spawn_launch(
backend,
image,
@@ -1643,6 +1679,7 @@ fn handle_command(
rows,
cols,
start_daemon,
install_first,
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
@@ -1751,7 +1788,7 @@ fn handle_command(
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}")));
spawn_launch(
sbx::Backend::Docker, image, owner, members, rows,
cols, false, pty, btx, atx,
cols, false, false, pty, btx, atx,
);
}
sbx::SnapKind::Multipass => {
@@ -1766,7 +1803,7 @@ fn handle_command(
spawn_launch(
sbx::Backend::Multipass,
sbx::Backend::Multipass.default_image().to_string(),
owner, members, rows, cols, false, pty, btx, atx,
owner, members, rows, cols, false, false, pty, btx, atx,
);
}
Ok(Err(e)) => {
@@ -2402,11 +2439,28 @@ fn spawn_launch(
rows: u16,
cols: u16,
start_daemon: bool,
install_first: bool,
pty_tx: UnboundedSender<Vec<u8>>,
broker_tx: UnboundedSender<BrokerMsg>,
app_tx: UnboundedSender<Net>,
) {
tokio::spawn(async move {
// Optional install step: the backend's binary was missing and the user
// opted in. Runs before provisioning; failure aborts the launch (and
// clears the *launching guard via BrokerMsg::Failed).
if install_first {
let res = tokio::task::spawn_blocking(move || match backend {
sbx::Backend::Docker => sbx::ensure_docker_install(),
sbx::Backend::Multipass => sbx::ensure_multipass_install(),
_ => Ok(()),
})
.await;
if let Err(e) = res.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("install failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
}
let name = SBX_NAME.to_string();
let prep = {
let (n, img) = (name.clone(), image.clone());
+63
View File
@@ -14,6 +14,8 @@ use std::sync::mpsc;
/// Helper that ensures the Docker daemon is running (ships in hh/scripts/).
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-docker.sh");
/// Detect-first Multipass installer (ships in hh/scripts/).
const ENSURE_MULTIPASS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-multipass.sh");
/// Detect-first VirtualBox installer (ships in hh/scripts/).
const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-vbox.sh");
/// Baseline dev-toolchain installer run inside Docker sandboxes (hh/scripts/).
@@ -23,6 +25,19 @@ const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandb
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
/// 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.
pub fn docker_installed() -> bool {
Command::new("docker")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
pub fn docker_daemon_up() -> bool {
Command::new("docker")
@@ -53,6 +68,54 @@ fn start_docker_daemon() -> Result<()> {
Ok(())
}
/// Install Docker via `ensure-docker.sh --install --yes` (Docker's official,
/// GPG-verified repo), then leave the daemon started. Consent is the caller's
/// job (they passed `install`); the script is idempotent if Docker is present.
/// Returns the script's last error line on failure (e.g. needs sudo).
pub fn ensure_docker_install() -> Result<()> {
let out = Command::new("bash")
.arg(ENSURE_DOCKER)
.arg("--install")
.arg("--yes")
.output()
.context("running ensure-docker.sh --install")?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
let last = err.lines().last().unwrap_or("could not install Docker");
anyhow::bail!("{last}");
}
Ok(())
}
/// Is Multipass installed? (`multipass version` succeeds.)
pub fn multipass_installed() -> bool {
Command::new("multipass")
.arg("version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Install Multipass via `ensure-multipass.sh --yes`. On Linux this is a snap
/// 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);
let last = err.lines().last().unwrap_or("could not install Multipass");
anyhow::bail!("{last}");
}
Ok(())
}
// ---- VirtualBox (local GUI VMs) ---------------------------------------------
// VirtualBox is integrated as a *local* facility rather than a shared-PTY
// backend: a room shares a VM by handing out its appliance, and each member
+4 -4
View File
@@ -154,12 +154,12 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
items: vec![
// ── launch (one verb per backend) ──
kv(
"/sbx launch docker [image]",
"Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain)",
"/sbx launch docker [image] [install]",
"Linux container — shared shell relayed to the room (default ubuntu:24.04 + auto dev toolchain; append install if Docker is missing)",
),
kv(
"/sbx launch multipass [image]",
"full Ubuntu VM — shared shell relayed to the room (same dev toolchain)",
"/sbx launch multipass [image] [install]",
"full Ubuntu VM — shared shell relayed to the room (same dev toolchain; append install if Multipass is missing)",
),
kv(
"/sbx launch vbox",