feat(sbx): create fresh VirtualBox VMs via cloud-init (/sbx launch vbox new)
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
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
The existing vbox paths only open pre-made images; there was no way to build one from scratch. Add `/sbx launch vbox new [name]` backed by scripts/vbox-new.sh: download an Ubuntu cloud image, convert it to a VDI, build a cloud-init NoCloud seed that creates a sudo login user and installs the sandbox-tools.json toolchain on first boot, then create + boot the VM. VirtualBox guests can't be exec'd into like docker/multipass, so cloud-init is the provisioning channel. Generates a one-time login password (or takes --pass) and authorizes a local SSH key if present. Runs off-thread; rolls back a half-built VM on failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+53
-10
@@ -1585,16 +1585,24 @@ fn handle_command(
|
||||
// guard. Grammar: `/sbx launch vbox [gui] <vm> [yes]`; a bare
|
||||
// `/sbx launch vbox` opens the arrow-navigable VM picker.
|
||||
let mut rest = pos.peekable();
|
||||
if rest.peek() == Some(&"gui") {
|
||||
rest.next(); // optional `gui` keyword (sugar)
|
||||
}
|
||||
match rest.next() {
|
||||
Some(vm) => {
|
||||
let confirmed = rest
|
||||
.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
|
||||
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
|
||||
if rest.peek() == Some(&"new") {
|
||||
// `/sbx launch vbox new [name]` — build a brand-new VM from
|
||||
// a cloud image (cloud-init toolchain), not an existing one.
|
||||
rest.next();
|
||||
let name = rest.next().unwrap_or("hh-vbox").to_string();
|
||||
launch_vbox_new(app, name, app.me.clone(), app_tx);
|
||||
} else {
|
||||
if rest.peek() == Some(&"gui") {
|
||||
rest.next(); // optional `gui` keyword (sugar)
|
||||
}
|
||||
match rest.next() {
|
||||
Some(vm) => {
|
||||
let confirmed = rest
|
||||
.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
|
||||
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
|
||||
}
|
||||
None => open_vbox_picker(app),
|
||||
}
|
||||
None => open_vbox_picker(app),
|
||||
}
|
||||
} else if app.sandbox.is_some() || broker.is_some() || *launching {
|
||||
app.sys("a sandbox is already running");
|
||||
@@ -1909,7 +1917,7 @@ fn handle_command(
|
||||
}
|
||||
}
|
||||
_ => app.sys(
|
||||
"usage: /sbx launch vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx gui <vm> alias · launch <docker|multipass> [image] (or local) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>",
|
||||
"usage: /sbx launch vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx launch vbox new [name] (fresh VM) · /sbx gui <vm> alias · launch <docker|multipass> [image] (or local) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>",
|
||||
),
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
||||
@@ -2192,6 +2200,41 @@ fn open_vbox_picker(app: &mut App) {
|
||||
/// VM image — or who'd have to stop another VM first — must re-issue with a
|
||||
/// trailing `yes`, which then installs VirtualBox, imports the shared appliance,
|
||||
/// and/or frees VT-x before booting.
|
||||
/// Build + boot a brand-new VirtualBox VM from a cloud image (the only path that
|
||||
/// creates a VM from scratch — `launch_vbox_gui` opens existing ones). The heavy
|
||||
/// lifting (image download, cloud-init seed, VBox create/boot) is in
|
||||
/// scripts/vbox-new.sh; we run it off-thread and report through `app_tx` so the
|
||||
/// first-run ~600MB download never stalls the TUI.
|
||||
fn launch_vbox_new(app: &mut App, name: String, user: String, app_tx: &UnboundedSender<Net>) {
|
||||
if !is_snap_label(&name) {
|
||||
app.sys("VM name must be alphanumerics, '.', '_' or '-'");
|
||||
return;
|
||||
}
|
||||
if !sbx::vbox_installed() {
|
||||
app.sys("VirtualBox isn't installed — `/sbx gui <vm> --install` or run ./scripts/ensure-vbox.sh first");
|
||||
return;
|
||||
}
|
||||
if sbx::vm_registered(&name) {
|
||||
app.sys(format!(
|
||||
"a VM named ‘{name}’ already exists — pick another name, or boot it with `/sbx gui {name}`"
|
||||
));
|
||||
return;
|
||||
}
|
||||
app.sys(format!(
|
||||
"building fresh VirtualBox VM ‘{name}’ (first run downloads a ~600MB Ubuntu cloud image; cloud-init installs the toolchain on boot)…"
|
||||
));
|
||||
let tx = app_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let (n, u) = (name.clone(), user);
|
||||
let res = tokio::task::spawn_blocking(move || sbx::vbox_new(&n, &u)).await;
|
||||
let _ = match res {
|
||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
|
||||
Ok(Err(e)) => tx.send(Net::Err(format!("vbox new failed: {e}"))),
|
||||
Err(e) => tx.send(Net::Err(format!("vbox new task: {e}"))),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn launch_vbox_gui(
|
||||
app: &mut App,
|
||||
vm: String,
|
||||
|
||||
@@ -20,6 +20,8 @@ const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/ensure-v
|
||||
const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-bootstrap.sh");
|
||||
/// Editable package schema the bootstrap installs — vim+curl always added.
|
||||
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
|
||||
/// 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 daemon accepting connections? (`docker info` succeeds.)
|
||||
pub fn docker_daemon_up() -> bool {
|
||||
@@ -186,6 +188,43 @@ pub fn gui_launch(name: &str) -> Result<String> {
|
||||
Ok(format!("launched {name} (GUI)"))
|
||||
}
|
||||
|
||||
/// Create + boot a *fresh* Ubuntu VirtualBox VM pre-provisioned with the dev
|
||||
/// toolchain, by driving `scripts/vbox-new.sh` (downloads a cloud image, builds
|
||||
/// a cloud-init NoCloud seed installing scripts/sandbox-tools.json, then boots
|
||||
/// the GUI). Unlike `/sbx launch vbox <vm>`, which opens an *existing* image,
|
||||
/// this builds one from scratch — the only path that doesn't need a pre-made VM.
|
||||
///
|
||||
/// Blocking and slow on first run (~600MB image download). Runs the script
|
||||
/// non-interactively (`--yes`); returns its final status line(s) — including the
|
||||
/// generated login password, which is shown exactly once.
|
||||
pub fn vbox_new(name: &str, user: &str) -> Result<String> {
|
||||
let out = Command::new("bash")
|
||||
.arg(VBOX_NEW)
|
||||
.args(["--yes", "--name", name, "--user", user])
|
||||
.output()
|
||||
.context("running vbox-new.sh (is bash available?)")?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"vbox-new failed: {}",
|
||||
stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim()
|
||||
);
|
||||
}
|
||||
// The script narrates progress on stderr. Surface the final success line and,
|
||||
// if one was generated, the one-time login password.
|
||||
let last = stderr
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('✓'))
|
||||
.unwrap_or("VM created")
|
||||
.trim();
|
||||
let pw = stderr.lines().find(|l| l.contains("login password"));
|
||||
Ok(match pw {
|
||||
Some(p) => format!("{last} · {}", p.trim()),
|
||||
None => last.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
|
||||
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
|
||||
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
|
||||
|
||||
@@ -165,6 +165,10 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
||||
"/sbx launch vbox",
|
||||
"VirtualBox VM picker — local GUI (↑↓ move · Enter/Tab boot · Esc dismiss)",
|
||||
),
|
||||
kv(
|
||||
"/sbx launch vbox new [name]",
|
||||
"build a FRESH Ubuntu VM from a cloud image (cloud-init installs the dev toolchain)",
|
||||
),
|
||||
kv(
|
||||
"/sbx launch vbox [gui] <vm> [yes]",
|
||||
"boot a VirtualBox VM's GUI on YOUR machine (non-host appends yes to install/import first)",
|
||||
|
||||
Reference in New Issue
Block a user